diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 94dd26f..cd3c825 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -83,4 +83,4 @@ jobs:
NEO4J_URI: bolt://localhost:7687
NEO4J_USER: neo4j
NEO4J_PASSWORD: testpassword
- run: pytest tests/checks -v --maxfail=5 -m "neo4j and not integration" --no-cov
+ run: pytest tests/ -v --maxfail=5 -m "neo4j and not integration" --no-cov
diff --git a/.gitignore b/.gitignore
index b53cea3..05dbb1f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,3 +37,8 @@ config.ini
.idea/
.vscode/
.DS_Store
+
+# Local notes / drafts
+LAB_REFERENCE.md
+BLOG_POST_V*.md
+PRODUCTION_READINESS_PLAN.md
diff --git a/README.md b/README.md
index 383e2da..9fa9e90 100644
--- a/README.md
+++ b/README.md
@@ -5,8 +5,8 @@
ADPathfinder is an attack mapping tool for pentesters and red teamers. It analyses SharpHound data and unifies it with OpenGraph plugins to surface attack paths to high-value targets such as Domain Admins and Domain Controllers, starting from low-privileged users and computers. MSSQLHound and ConfigManBearPig are supported natively, extending coverage across AD, ADCS, SCCM, and MSSQL.
-
-
+
+
@@ -59,16 +59,16 @@ This stores the BloodHound CE API settings used for import and delete operations
-Import BloodHound data
+Import SharpHound data
```bash
-adpathfinder --import BloodHound.zip
+adpathfinder --import SharpHound.zip
```
-Multiple BloodHound and OpenGraph plugin zip files can be imported in one command:
+Multiple SharpHound and OpenGraph plugin zip files can be imported in one command:
```bash
-adpathfinder --import BloodHound.zip MSSQLHound.zip ConfigManBearPig.zip
+adpathfinder --import SharpHound.zip MSSQLHound.zip ConfigManBearPig.zip
```
@@ -77,7 +77,7 @@ adpathfinder --import BloodHound.zip MSSQLHound.zip ConfigManBearPig.zip
Import data and run a full audit
```bash
-adpathfinder -i BloodHound.zip MSSQLHound.zip ConfigManBearPig.zip --ad --pwd Contoso,ContosoIT --ntds ntds.txt -p hashcat.potfile
+adpathfinder -i SharpHound.zip MSSQLHound.zip ConfigManBearPig.zip --ad --pwd Contoso,ContosoIT --ntds ntds.txt -p hashcat.potfile
```
Use only the zip files you have. The `--pwd` value is not the AD domain; it is a comma separated list of company, brand, or organisation terms to flag in cracked passwords.
@@ -117,7 +117,7 @@ adpathfinder --ad --pwd Contoso,ContosoIT --ntds ntds.txt -p hashcat.potfile --d
| Stage | What happens |
| --- | --- |
-| **Ingest** | Merges BloodHound CE and supported OpenGraph zips into one graph. MSSQLHound and ConfigManBearPig data are supported directly |
+| **Ingest** | Merges SharpHound and supported OpenGraph zips into one graph. MSSQLHound and ConfigManBearPig data are supported directly |
| **Map** | Finds the shortest paths from users and computers to high-value targets. You can [configure the edge set](https://github.com/NetSPI/AD-PathFinder/wiki/Excluded-Relationships) to remove noisy edges like `HasSession` |
| **Group** | Groups accounts that share a path, so repeated paths are reported as one finding instead of many near-duplicates |
| **Pair** | Compares NTDS hashes and hashcat potfiles with graph data to show which paths are usable during the assessment |
@@ -131,16 +131,16 @@ adpathfinder --ad --pwd Contoso,ContosoIT --ntds ntds.txt -p hashcat.potfile --d
## AD HTML Report
-
-
+
+
## Password Audit HTML Report
-
-
+
+
diff --git a/checks/core/base.py b/checks/core/base.py
index 65ef41a..4684208 100644
--- a/checks/core/base.py
+++ b/checks/core/base.py
@@ -5,6 +5,22 @@
from .dependencies import CheckDependencies
from .constants import DataTypes, EntityTypes, DisplayTypes
+from .display.escalation import (
+ EscalationPathDisplayHandler,
+ GroupedEscalationDisplayHandler,
+)
+from .display.group_analysis import GroupAnalysisDisplayHandler
+from .display.shared_graph_paths import SharedGraphPathDisplayHandler
+from .display.simple import SimpleDisplayHandler
+
+_DISPLAY_HANDLERS = {
+ DisplayTypes.GROUPED_ESCALATION_PATHS: GroupedEscalationDisplayHandler,
+ DisplayTypes.ESCALATION_PATHS: EscalationPathDisplayHandler,
+ DisplayTypes.GROUP_ANALYSIS: GroupAnalysisDisplayHandler,
+ DisplayTypes.SHARED_GRAPH_PATHS: SharedGraphPathDisplayHandler,
+ DisplayTypes.SIMPLE: SimpleDisplayHandler,
+}
+
class VulnerabilityCheck:
RISK_LEVEL = "High"
@@ -187,15 +203,13 @@ def format_relay_finding(self, coerce_source: str | None, target: str, *,
def get_display_method(self) -> Callable[..., Any]:
suppress = getattr(self, 'suppress_terminal_output', False)
- if self.DISPLAY_TYPE == DisplayTypes.GROUPED_ESCALATION_PATHS:
- from checks.core.display.escalation import GroupedEscalationDisplayHandler
- return GroupedEscalationDisplayHandler(suppress, self, self.sid_mapper).display
- elif DataTypes.ESCALATION_PATHS in self.REQUIRED_DATA and self.DISPLAY_TYPE == DisplayTypes.ESCALATION_PATHS:
- from checks.core.display.escalation import EscalationPathDisplayHandler
- return EscalationPathDisplayHandler(suppress, self, self.sid_mapper).display
- elif self.DISPLAY_TYPE == DisplayTypes.GROUP_ANALYSIS:
- from checks.core.display.group_analysis import GroupAnalysisDisplayHandler
- return GroupAnalysisDisplayHandler(suppress, self, self.sid_mapper).display
- else:
- from checks.core.display.simple import SimpleDisplayHandler
- return SimpleDisplayHandler(suppress, self, self.sid_mapper).display
\ No newline at end of file
+ display_type = DisplayTypes(self.DISPLAY_TYPE)
+
+ if (
+ display_type == DisplayTypes.ESCALATION_PATHS
+ and DataTypes.ESCALATION_PATHS not in self.REQUIRED_DATA
+ ):
+ display_type = DisplayTypes.SIMPLE
+
+ handler_cls = _DISPLAY_HANDLERS[display_type]
+ return handler_cls(suppress, self, self.sid_mapper).display
diff --git a/checks/core/constants.py b/checks/core/constants.py
index 958178e..175137b 100644
--- a/checks/core/constants.py
+++ b/checks/core/constants.py
@@ -28,6 +28,7 @@ class DisplayTypes(StrEnum):
ESCALATION_PATHS = 'escalation_paths'
GROUPED_ESCALATION_PATHS = 'grouped_escalation_paths'
GROUP_ANALYSIS = 'group_analysis'
+ SHARED_GRAPH_PATHS = 'shared_graph_paths'
class DisplaySymbols:
diff --git a/checks/core/display/base.py b/checks/core/display/base.py
new file mode 100644
index 0000000..6e11251
--- /dev/null
+++ b/checks/core/display/base.py
@@ -0,0 +1,27 @@
+from colorama import Style
+
+
+class BaseDisplayHandler:
+ def __init__(self, suppress_terminal_output=False, check_instance=None, sid_mapper=None):
+ self.suppress_terminal_output = suppress_terminal_output
+ self.check_instance = check_instance
+ self.sid_mapper = sid_mapper
+ self.reset_color = ""
+
+ def _emit_heading(self, category_color, category, content, count, reset_color):
+ if not self.suppress_terminal_output:
+ print(f"\n {Style.BRIGHT}{category_color}{category}: {count}{reset_color}")
+ content.append(f"\n {category}: {count}")
+
+ def _emit(self, line, content):
+ if not self.suppress_terminal_output:
+ print(line)
+ content.append(line)
+
+ def _output_line(self, line, content):
+ if not self.suppress_terminal_output:
+ print(f"{self.reset_color}{line}")
+ content.append(line)
+
+ def display(self, results, category_color, category, content, count, reset_color):
+ raise NotImplementedError
diff --git a/checks/core/display/escalation.py b/checks/core/display/escalation.py
index 2d2bffc..2e9dfe1 100644
--- a/checks/core/display/escalation.py
+++ b/checks/core/display/escalation.py
@@ -1,53 +1,51 @@
from collections import defaultdict
-from colorama import Style
-from ..constants import DataTypes, DisplaySymbols, is_sid
+from .base import BaseDisplayHandler
+from ..constants import DataTypes, DisplaySymbols, EntityTypes, is_sid
from modules.node_type_cache import extract_ad_report_type_from_labels
-class EscalationPathDisplayHandler:
- def __init__(self, suppress_terminal_output=False, check_instance=None, sid_mapper=None):
- self.suppress_terminal_output = suppress_terminal_output
- self.check_instance = check_instance
- self.sid_mapper = sid_mapper
+def _extract_display_info(description):
+ if not description:
+ return ""
+ if " (" in description and description.endswith(")"):
+ return description.split(" (")[-1][:-1]
+ return description
+
+class EscalationPathDisplayHandler(BaseDisplayHandler):
def display(self, results, category_color, category, content, count, reset_color):
- if not self.suppress_terminal_output:
- print(f"\n {Style.BRIGHT}{category_color}{category}: {count}{reset_color}")
- content.append(f"\n {category}: {count}")
+ self._emit_heading(category_color, category, content, count, reset_color)
+
+ total_items = len(results)
+ for index, (entity_identifier, item_data) in enumerate(results.items()):
+ if total_items > 1 and index > 0:
+ self._emit("", content)
- for entity_identifier, item_data in results.items():
if isinstance(item_data, dict):
description = item_data.get('description', '')
else:
description = item_data
- display_info = description.split(" (")[-1][:-1] if " (" in description and description.endswith(")") else description if description else ""
+ display_info = _extract_display_info(description)
display_name = self.sid_mapper.get_display_name(entity_identifier)
if display_info:
line = f"{DisplaySymbols.MAIN_ITEM}{display_name} ({display_info})"
else:
line = f"{DisplaySymbols.MAIN_ITEM}{display_name}"
- if not self.suppress_terminal_output:
- print(line)
- content.append(line)
+ self._emit(line, content)
escalation_paths = self._get_escalation_path_details(entity_identifier)
if escalation_paths:
- # path is [Node, Rel, Node, Rel, Node] — count relationships not elements
steps = len(escalation_paths) // 2
final_target = escalation_paths[-1] if escalation_paths else "Unknown"
leads_line = f" {DisplaySymbols.PATH_CONNECTOR} LEADS TO{DisplaySymbols.PATH_ARROW}{final_target} (via {steps} steps)"
- if not self.suppress_terminal_output:
- print(leads_line)
- content.append(leads_line)
+ self._emit(leads_line, content)
path_str = DisplaySymbols.PATH_ARROW.join(escalation_paths)
path_line = f" {DisplaySymbols.PATH_CONNECTOR} {path_str}"
- if not self.suppress_terminal_output:
- print(path_line)
- content.append(path_line)
+ self._emit(path_line, content)
return content
@@ -86,7 +84,6 @@ def _find_escalation_path_data(self, entity_sid):
def _format_escalation_path(self, full_path):
path_elements = []
- # fullPath is interleaved: node, rel, node, rel, ..., final_node
for item in full_path:
if isinstance(item, dict):
formatted_node = self._format_path_node(item)
@@ -115,22 +112,15 @@ def _format_path_node(self, node):
return None
-class GroupedEscalationDisplayHandler:
- def __init__(self, suppress_terminal_output=False, check_instance=None, sid_mapper=None):
- self.suppress_terminal_output = suppress_terminal_output
- self.check_instance = check_instance
- self.sid_mapper = sid_mapper
-
+class GroupedEscalationDisplayHandler(BaseDisplayHandler):
def display(self, results, category_color, category, content, count, reset_color):
if not results:
return content
- if not self.suppress_terminal_output:
- print(f"\n {Style.BRIGHT}{category_color}{category}: {count}{reset_color}")
- content.append(f"\n {category}: {count}")
+ self._emit_heading(category_color, category, content, count, reset_color)
entity_type = getattr(self.check_instance, 'ENTITY_TYPE', 'user')
- entity_label = "Computers" if entity_type == 'computer' else "Users"
+ entity_label = "Computers" if entity_type == EntityTypes.COMPUTER else "Users"
path_to_entities = defaultdict(set)
@@ -154,20 +144,20 @@ def display(self, results, category_color, category, content, count, reset_color
reverse=True
)
- for path_str, entities_set in sorted_paths:
+ for index, (path_str, entities_set) in enumerate(sorted_paths):
+ if index > 0:
+ self._emit("", content)
+
sorted_entities = sorted(entities_set)
entities_str = ", ".join(sorted_entities)
entities_count = len(entities_set)
line1 = f" {entity_label} with Shared Path ({entities_count}): {entities_str}"
- if not self.suppress_terminal_output:
- print(line1)
- content.append(line1)
-
- line2 = f" Common Escalation Path: {path_str}\n"
- if not self.suppress_terminal_output:
- print(line2)
- content.append(line2)
+ self._emit(line1, content)
+
+ self._emit("", content)
+ line2 = f" Common Escalation Path: {path_str}"
+ self._emit(line2, content)
return content
diff --git a/checks/core/display/group_analysis.py b/checks/core/display/group_analysis.py
index 0f60469..f8b42b0 100644
--- a/checks/core/display/group_analysis.py
+++ b/checks/core/display/group_analysis.py
@@ -1,12 +1,9 @@
-from colorama import Style
+from .base import BaseDisplayHandler
-class GroupAnalysisDisplayHandler:
+class GroupAnalysisDisplayHandler(BaseDisplayHandler):
def __init__(self, suppress_terminal_output=False, check_instance=None, sid_mapper=None):
- self.suppress_terminal_output = suppress_terminal_output
- self.check_instance = check_instance
- self.sid_mapper = sid_mapper
- self.reset_color = ""
+ super().__init__(suppress_terminal_output, check_instance, sid_mapper)
self._account_analysis = getattr(check_instance, 'account_analysis', None)
def display(self, results, category_color, category, content, count, reset_color):
@@ -15,9 +12,7 @@ def display(self, results, category_color, category, content, count, reset_color
display_groups = {k: v for k, v in results.items() if not k.startswith("___")}
group_count = len(display_groups)
- if not self.suppress_terminal_output:
- print(f"\n {Style.BRIGHT}{category_color}{category}: {group_count}{reset_color}")
- content.append(f"\n {category}: {group_count}")
+ self._emit_heading(category_color, category, content, group_count, reset_color)
enhanced_data_ci = {k.upper(): k for k in enhanced_data.keys()}
@@ -42,6 +37,9 @@ def display(self, results, category_color, category, content, count, reset_color
if not isinstance(path_obj, dict):
continue
+ if path_count > 1 and idx > 0:
+ self._output_line("", content)
+
is_last = idx == path_count - 1
path_prefix = " └─" if is_last else " ├─"
cont_prefix = " " if is_last else " │ "
@@ -109,8 +107,3 @@ def _render_escalation(self, esc_path, has_esc, cont_prefix, content):
content
)
self._output_line(f"{cont_prefix} └─ {esc_str}", content)
-
- def _output_line(self, line, content):
- if not self.suppress_terminal_output:
- print(f"{self.reset_color}{line}")
- content.append(line)
diff --git a/checks/core/display/shared_graph_paths.py b/checks/core/display/shared_graph_paths.py
new file mode 100644
index 0000000..0fb234e
--- /dev/null
+++ b/checks/core/display/shared_graph_paths.py
@@ -0,0 +1,70 @@
+from .base import BaseDisplayHandler
+from ..constants import DisplaySymbols
+
+
+class SharedGraphPathDisplayHandler(BaseDisplayHandler):
+ def display(self, results, category_color, category, content, count, reset_color):
+ self._emit_heading(category_color, category, content, count, reset_color)
+
+ total_items = len(results)
+ for index, (sid, item) in enumerate(results.items()):
+ if total_items > 1 and index > 0:
+ self._emit("", content)
+
+ description = item.get('description') if isinstance(item, dict) else item
+ description = str(description) if description is not None else ''
+ display_name = self.sid_mapper.get_display_name(sid)
+ self._emit(f"{DisplaySymbols.MAIN_ITEM}{display_name}", content)
+ for line in description.split('\n'):
+ node_chain = self._node_chain(line)
+ if node_chain:
+ self._emit(f" {node_chain}", content)
+
+ return content
+
+ def _node_chain(self, line):
+ line = line.strip()
+ if not line:
+ return ''
+ path, scope = self._split_scope(line)
+ tokens = self._tokens(path)
+ scope_tokens = self._tokens(scope)
+
+ normalized_nodes = {
+ self._normalize_node(token) for token in self._node_tokens(tokens)
+ }
+ for token in self._node_tokens(scope_tokens):
+ normalized = self._normalize_node(token)
+ if normalized and normalized not in normalized_nodes:
+ tokens.append(token)
+ normalized_nodes.add(normalized)
+
+ return " > ".join(tokens)
+
+ def _split_scope(self, line):
+ path, sep, scope = line.partition(' | ')
+ return path.strip(), scope.strip() if sep else ''
+
+ def _tokens(self, chain):
+ tokens = []
+ for token in [part.strip() for part in chain.split(' -> ')]:
+ if not token:
+ continue
+ tokens.append(token)
+ return tokens
+
+ def _node_tokens(self, tokens):
+ if not tokens:
+ return []
+ start = 0 if len(tokens) % 2 == 1 else 1
+ return [
+ token for index, token in enumerate(tokens)
+ if index >= start and (index - start) % 2 == 0
+ ]
+
+ def _normalize_node(self, node):
+ node = (node or '').strip().lower()
+ for wrapper in ('mssql_database(', 'sccm_site('):
+ if node.startswith(wrapper) and node.endswith(')'):
+ return node[len(wrapper):-1]
+ return node
diff --git a/checks/core/display/simple.py b/checks/core/display/simple.py
index 371eaa6..7e222cc 100644
--- a/checks/core/display/simple.py
+++ b/checks/core/display/simple.py
@@ -1,14 +1,8 @@
-from colorama import Style
+from .base import BaseDisplayHandler
from ..constants import DataTypes, DisplaySymbols
-class SimpleDisplayHandler:
- def __init__(self, suppress_terminal_output=False, check_instance=None, sid_mapper=None):
- self.suppress_terminal_output = suppress_terminal_output
- self.check_instance = check_instance
- self.sid_mapper = sid_mapper
- self.reset_color = ""
-
+class SimpleDisplayHandler(BaseDisplayHandler):
def _format_entity_display(self, item_name, item_data):
display_name = self.sid_mapper.get_display_name(item_name)
display_name = self._format_with_password(display_name, item_name)
@@ -46,7 +40,6 @@ def _format_with_password(self, display_name, entity_sid):
return display_name
if not hasattr(self.check_instance, 'account_analysis') or not self.check_instance.account_analysis:
return display_name
- # only show passwords with explicit --unsafe-report flag
if not (hasattr(self.check_instance.account_analysis, 'output_format') and
self.check_instance.account_analysis.output_format == 'unsafe'):
return display_name
@@ -55,19 +48,15 @@ def _format_with_password(self, display_name, entity_sid):
return f"{display_name}:{password_display}"
return display_name
- def _output_line(self, line, content):
- if not self.suppress_terminal_output:
- print(f"{self.reset_color}{line}")
- content.append(line)
-
def display(self, results, category_color, category, content, count, reset_color):
self.reset_color = reset_color
- if not self.suppress_terminal_output:
- print(f"\n {Style.BRIGHT}{category_color}{category}: {count}{reset_color}")
- content.append(f"\n {category}: {count}")
+ self._emit_heading(category_color, category, content, count, reset_color)
- for item_name, item_data in results.items():
+ total_items = len(results)
+ for index, (item_name, item_data) in enumerate(results.items()):
+ if total_items > 1 and index > 0:
+ self._output_line("", content)
main_line, desc_line = self._format_entity_display(item_name, item_data)
self._output_line(main_line, content)
if desc_line:
diff --git a/checks/core/high_value.py b/checks/core/high_value.py
new file mode 100644
index 0000000..f3174b9
--- /dev/null
+++ b/checks/core/high_value.py
@@ -0,0 +1,96 @@
+from modules.hv_groups import HIGH_VALUE_GROUPS_UPPER
+
+
+def name_variants(name):
+ if not name:
+ return set()
+ upper = str(name).upper()
+ variants = {upper}
+ account_part = upper.split('@')[0]
+ variants.add(account_part)
+ if '\\' in account_part:
+ variants.add(account_part.split('\\')[-1])
+ return {variant for variant in variants if variant}
+
+
+def _add_variants(target, names):
+ for name in names or []:
+ target.update(name_variants(name))
+
+
+def high_value_principal_sets(neo4j_data):
+ users = set()
+ computers = set()
+ groups = set(HIGH_VALUE_GROUPS_UPPER)
+
+ admin_users, admin_computers = neo4j_data.get_admin_users_and_computers()
+ _add_variants(users, admin_users)
+ _add_variants(computers, admin_computers)
+
+ _add_variants(users, getattr(neo4j_data, 'high_value_users_cache', None))
+ _add_variants(computers, getattr(neo4j_data, 'high_value_computers_cache', None))
+
+ return users, computers, groups
+
+
+def enrich_high_value_principal_sets(
+ users,
+ computers,
+ groups,
+ query,
+ domain_condition="",
+ query_name="high_value_graph_principals",
+):
+ for row in query(f"""
+ MATCH (p)
+ WHERE (p:User OR p:Computer OR p:Group)
+ AND p.name IS NOT NULL
+ AND (
+ p.highvalue = true
+ OR p.admincount = true
+ OR p:Tag_Tier_Zero
+ OR coalesce(p.system_tags, '') CONTAINS 'admin_tier_0'
+ ){domain_condition}
+ RETURN DISTINCT p.name AS name,
+ CASE
+ WHEN p:User THEN 'User'
+ WHEN p:Computer THEN 'Computer'
+ WHEN p:Group THEN 'Group'
+ END AS type
+ """, name=query_name) or []:
+ target = None
+ if row.get('type') == 'User':
+ target = users
+ elif row.get('type') == 'Computer':
+ target = computers
+ elif row.get('type') == 'Group':
+ target = groups
+ if target is not None:
+ target.update(name_variants(row.get('name')))
+
+ return users, computers, groups
+
+
+def high_value_principal_sets_for_check(check, query_name="high_value_graph_principals"):
+ users, computers, groups = high_value_principal_sets(check.neo4j_data)
+ return enrich_high_value_principal_sets(
+ users,
+ computers,
+ groups,
+ check.query,
+ check._domain_condition("p"),
+ query_name,
+ )
+
+
+def is_high_value_principal(name, principal_type, users, computers, groups):
+ variants = name_variants(name)
+ if not variants:
+ return False
+ if principal_type == 'User':
+ return bool(variants & users)
+ if principal_type == 'Computer':
+ return bool(variants & computers)
+ if principal_type == 'Group':
+ return bool(variants & groups)
+ return False
diff --git a/checks/core/manager.py b/checks/core/manager.py
index a602288..acfe221 100644
--- a/checks/core/manager.py
+++ b/checks/core/manager.py
@@ -12,6 +12,7 @@ def __init__(self, neo4j_data, has_escalation_path_results=None, full_escalation
self.account_analysis = account_analysis
self._diagnostics = diagnostics
self._diagnostic_domain = getattr(neo4j_data, '_domain_filter', None)
+ self._diagnostic_query_start_index = 0
self.shared_cache = {
DataTypes.ESCALATION_PATHS: has_escalation_path_results or {},
DataTypes.FULL_ESCALATION_PATHS: full_escalation_cache or {}
@@ -35,6 +36,7 @@ def run_all_checks(self):
self._check_datasource_availability()
if self._diagnostics:
+ self._diagnostic_query_start_index = len(self._diagnostics.queries)
self._record_entity_summary()
runnable = [c for c in CheckRegistry.get_all_checks() if not self._should_skip(c)]
@@ -96,7 +98,7 @@ def _collect_required_data_types(self, check_classes):
def _preload_data_type(self, data_type):
if data_type in self.shared_cache:
- return # Already cached
+ return
loader_methods = {
DataTypes.COMPUTERS: 'get_all_computers_with_attributes',
@@ -278,15 +280,48 @@ def _record_platform_summary(self):
if not mssql and not sccm:
return
domain_key = self._diagnostic_domain or self.neo4j_data.get_domain_name()
+ queries = self._diagnostics.queries[self._diagnostic_query_start_index:]
bucket = {}
if mssql:
- bucket["mssql"] = {
- "checks_run": len(mssql),
- "total_findings": sum(c['findings_count'] for c in mssql),
- }
+ bucket["mssql"] = self._platform_perf_summary(
+ mssql,
+ self._queries_matching(queries, ("mssql",))
+ )
if sccm:
- bucket["sccm"] = {
- "checks_run": len(sccm),
- "total_findings": sum(c['findings_count'] for c in sccm),
- }
+ bucket["sccm"] = self._platform_perf_summary(
+ sccm,
+ self._queries_matching(
+ queries,
+ ("sccm", "takeover", "elevate", "pxe", "management_points")
+ )
+ )
self._diagnostics.mssql_sccm[domain_key] = bucket
+
+ def _queries_matching(self, queries, markers):
+ return [
+ q for q in queries
+ if any(marker in (q.get("name") or "").lower() for marker in markers)
+ ]
+
+ def _platform_perf_summary(self, checks, queries):
+ slowest_check = max(checks, key=lambda c: c.get('duration_ms') or 0)
+ summary = {
+ "checks_run": len(checks),
+ "total_findings": sum(c['findings_count'] for c in checks),
+ "check_duration_ms": round(sum(c.get('duration_ms') or 0 for c in checks), 1),
+ "slowest_check": {
+ "name": slowest_check.get("name"),
+ "duration_ms": slowest_check.get("duration_ms") or 0,
+ },
+ "queries_run": len(queries),
+ "query_duration_ms": round(sum(q.get('duration_ms') or 0 for q in queries), 1),
+ "slowest_query": None,
+ }
+ if queries:
+ slowest_query = max(queries, key=lambda q: q.get('duration_ms') or 0)
+ summary["slowest_query"] = {
+ "name": slowest_query.get("name"),
+ "duration_ms": slowest_query.get("duration_ms") or 0,
+ "result_count": slowest_query.get("result_count") or 0,
+ }
+ return summary
diff --git a/checks/core/mssql_common.py b/checks/core/mssql_common.py
new file mode 100644
index 0000000..04cbea8
--- /dev/null
+++ b/checks/core/mssql_common.py
@@ -0,0 +1,77 @@
+COMMON_SQL_LOGIN_HOLDER_NAMES = [
+ 'DOMAIN USERS', 'DOMAIN COMPUTERS', 'AUTHENTICATED USERS',
+ 'EVERYONE', 'BUILTIN\\USERS', 'USERS',
+]
+
+COMMON_SQL_LOGIN_HOLDER_SIDS = [
+ 'S-1-1-0', 'S-1-5-11', 'S-1-5-32-545',
+]
+
+MSSQL_ABUSE_EDGES = [
+ 'MSSQL_HasLogin', 'MSSQL_Connect', 'MSSQL_IsMappedTo', 'MSSQL_MemberOf',
+ 'MSSQL_AddMember', 'MSSQL_ControlServer', 'MSSQL_ControlDB', 'MSSQL_Control',
+ 'MSSQL_AlterAnyLogin', 'MSSQL_AlterAnyDBRole', 'MSSQL_AlterAnyAppRole',
+ 'MSSQL_AlterAnyServerRole', 'MSSQL_GrantAnyPermission', 'MSSQL_GrantAnyDBPermission',
+ 'MSSQL_Impersonate', 'MSSQL_ImpersonateAnyLogin', 'MSSQL_ExecuteAs',
+ 'MSSQL_ExecuteAsOwner', 'MSSQL_TakeOwnership', 'MSSQL_ChangeOwner',
+ 'MSSQL_ChangePassword', 'MSSQL_LinkedAsAdmin', 'MSSQL_Owns',
+ 'MSSQL_HasMappedCred', 'MSSQL_HasProxyCred', 'MSSQL_HasDBScopedCred',
+]
+
+
+def sql_login_holder_parameters():
+ return {
+ "_common_sql_login_holder_names": COMMON_SQL_LOGIN_HOLDER_NAMES,
+ "_common_sql_login_holder_sids": COMMON_SQL_LOGIN_HOLDER_SIDS,
+ }
+
+
+def sql_login_report_holder_expr(holder_var="holder"):
+ return f"""{holder_var}:Group AND (
+ toUpper(split(coalesce({holder_var}.name, ''), '@')[0]) IN $_common_sql_login_holder_names
+ OR coalesce({holder_var}.objectid, '') IN $_common_sql_login_holder_sids
+ OR coalesce({holder_var}.objectid, '') ENDS WITH '-513'
+ OR coalesce({holder_var}.objectid, '') ENDS WITH '-515'
+ )"""
+
+
+def sql_login_report_holder_filter(
+ holder_domain_cond,
+ candidate_domain_cond,
+ holder_var="holder",
+ candidate_var="candidatePrincipal",
+):
+ # Caller must alias sql_login_report_holder_expr() as reportHolder.
+ return f"""WHERE ((reportHolder OR {holder_var}:User OR {holder_var}:Computer){holder_domain_cond})
+ OR ({holder_var}:Group AND NOT reportHolder AND EXISTS {{
+ MATCH ({candidate_var})-[:MemberOf*1..6]->({holder_var})
+ WHERE ({candidate_var}:User OR {candidate_var}:Computer){candidate_domain_cond}
+ }})"""
+
+
+def primary_label_expr(var):
+ return (
+ f"head([label IN labels({var}) "
+ f"WHERE NOT label IN ['Base', 'MSSQL_Base', 'SCCM_Base', "
+ f"'OpenGraph_Stub', 'ADLocalGroup', 'LocalGroup'] "
+ f"AND NOT label STARTS WITH 'Tag_'])"
+ )
+
+
+def linked_server_target_resolution(real_var, stub_var):
+ # Match only exact names or delimiter-anchored host prefixes.
+ return (
+ f"({stub_var}.name IS NOT NULL AND {stub_var}.name <> '' "
+ f"AND (toLower({real_var}.name) = toLower({stub_var}.name) "
+ f"OR toLower({real_var}.name) STARTS WITH toLower({stub_var}.name) + '.' "
+ f"OR toLower({real_var}.name) STARTS WITH toLower({stub_var}.name) + ':'))"
+ )
+
+
+def host_sid_resolves_to_single_server(sid_expr):
+ # Avoid bridging ambiguous host SID prefixes shared by sibling instances.
+ return (
+ f"COUNT {{ MATCH (sidServer:MSSQL_Server) "
+ f"WHERE sidServer.objectid CONTAINS ':' "
+ f"AND split(sidServer.objectid, ':')[0] = {sid_expr} }} = 1"
+ )
diff --git a/checks/cross_domain/injector.py b/checks/cross_domain/injector.py
index 82819ff..82e1f21 100644
--- a/checks/cross_domain/injector.py
+++ b/checks/cross_domain/injector.py
@@ -4,7 +4,10 @@
def _display_results(results, category_color, category, content, count, reset_color):
content.append(f"\n {category}: {count}")
- for display_key, description in results.items():
+ total_items = len(results)
+ for index, (display_key, description) in enumerate(results.items()):
+ if total_items > 1 and index > 0:
+ content.append("")
if not description:
content.append(f" ▶ {display_key}")
elif description.lstrip().startswith(('├─', '└─')):
@@ -18,13 +21,15 @@ def _display_results(results, category_color, category, content, count, reset_co
def _display_grouped_paths(results, category_color, category, content, count, reset_color):
content.append(f"\n {category}: {count}")
- for display_key, description in results.items():
+ for index, (display_key, description) in enumerate(results.items()):
+ if index > 0:
+ content.append("")
# display_key is "Users with Shared Path (N): user1, user2"
# description is "Cross-Domain Path: MemberOf -> ..."
content.append(f" {display_key}")
if description:
+ content.append("")
content.append(f" {description}")
- content.append("") # Blank line between groups
return content
diff --git a/checks/mssql_logins.py b/checks/mssql_logins.py
index b8432f0..5180756 100644
--- a/checks/mssql_logins.py
+++ b/checks/mssql_logins.py
@@ -136,8 +136,6 @@ def _format_entity(self, rows):
if dinfo['permissions']:
sub.append(f"Permissions: {', '.join(dinfo['permissions'])}")
- if 'MSSQL_ControlDB' in dinfo['capabilities']:
- sub.append("ATTACK PATH: Full database control")
if 'MSSQL_GrantAnyDBPermission' in dinfo['capabilities']:
sub.append("ATTACK PATH: Can grant any database permission")
diff --git a/checks/mssql_ntlm_relay.py b/checks/mssql_ntlm_relay.py
index ca8d035..391c2a0 100644
--- a/checks/mssql_ntlm_relay.py
+++ b/checks/mssql_ntlm_relay.py
@@ -1,9 +1,20 @@
from collections import defaultdict
from checks.core import Check, check
+from checks.core.high_value import high_value_principal_sets_for_check, is_high_value_principal
from checks.core.platform_mixins import MSSQLDomainMixin
from modules.opengraph_contracts import mssql_host_mapping_requirement
+_COMMON_GROUP_NAMES = {
+ 'DOMAIN USERS',
+ 'DOMAIN COMPUTERS',
+ 'AUTHENTICATED USERS',
+ 'EVERYONE',
+ 'USERS',
+}
+
+_MAX_RELAY_PRINCIPALS = 5
+
@check(risk="High", category="MSSQL Server Vulnerable to NTLM Relay", entity="computer", data=[], requires=["mssql"])
class MSSQLNTLMRelayCheck(MSSQLDomainMixin, Check):
@@ -18,8 +29,10 @@ def execute(self):
login_info = self._get_login_info()
relay_targets = self._get_relay_targets()
+ high_value_sets = self._high_value_start_sets()
per_sid = defaultdict(list)
+ seen_by_sid = defaultdict(set)
for row in servers:
display_account = row.get('resolvedAccount')
if not display_account:
@@ -27,31 +40,33 @@ def execute(self):
server_name = row.get('serverName')
host_sid = row.get('hostSid')
- host_name = row.get('hostName')
- if not server_name or not host_sid or not host_name:
+ host_display = row.get('hostName')
+ if not server_name or not host_sid or not host_display:
continue
server_lower = server_name.lower()
- host_name = host_name.lower()
+ host_name = host_display.lower()
server_logins = login_info.get(server_lower)
if not server_logins:
continue
- who_str = self._format_who_can_login(server_logins)
-
per_server_targets = [
display for target_name, display in relay_targets
if target_name != host_name
]
- if per_server_targets:
- relay_str = f"Relay to {', '.join(per_server_targets)}"
- else:
- relay_str = "No current relay targets with SMB signing disabled"
-
- desc = f"{who_str} -> xp_dirtree coercion ({display_account}) -> {relay_str}"
- if desc not in per_sid[host_sid]:
- per_sid[host_sid].append(desc)
+ desc = self._format_relay_path(
+ self._login_principals(server_logins, high_value_sets),
+ host_display,
+ display_account,
+ per_server_targets,
+ )
+ if not desc:
+ continue
+ if desc in seen_by_sid[host_sid]:
+ continue
+ seen_by_sid[host_sid].add(desc)
+ per_sid[host_sid].append(desc)
return {sid: self.finding('\n'.join(descs)) for sid, descs in per_sid.items()}
@@ -123,8 +138,12 @@ def _get_login_info(self):
if server_lower not in info:
info[server_lower] = {'users': [], 'computers': [], 'groups': []}
+ login_type = (row.get('type') or '').upper()
+
if name_upper.startswith('BUILTIN\\'):
info[server_lower]['groups'].append(name.split('\\', 1)[1])
+ elif login_type == 'WINDOWS_GROUP':
+ info[server_lower]['groups'].append(name)
elif name.endswith('$'):
info[server_lower]['computers'].append(name)
else:
@@ -155,38 +174,113 @@ def _get_relay_targets(self):
for row in rows if row.get('name')
]
- def _format_who_can_login(self, logins):
- who = []
+ def _high_value_start_sets(self):
+ return high_value_principal_sets_for_check(
+ self,
+ query_name="mssql_ntlm_high_value_start_principals",
+ )
+
+ def _format_relay_path(self, principals, host_name, service_account, relay_targets):
+ relay_target = ', '.join(relay_targets) if relay_targets else "no SMB signing-disabled relay targets"
+ if isinstance(principals, str):
+ principals = [principals]
+ if not principals:
+ return ""
+ shown = principals[:_MAX_RELAY_PRINCIPALS]
+ lines = [
+ f"{principal} > MSSQL_Connect > {host_name} > xp_dirtree > "
+ f"{service_account} > NTLM_Relay > {relay_target}"
+ for principal in shown
+ ]
+ remaining = len(principals) - len(shown)
+ if remaining:
+ lines.append(f"(+{remaining} more principals)")
+ return '\n'.join(lines)
+
+ def _login_principals(self, logins, high_value_sets=None):
+ principals = []
has_broad = False
for group in logins.get('groups', []):
- if 'DOMAIN USERS' in group.upper():
- who.append(f"{group} (all domain users)")
+ if self._is_filtered_start_principal(group, 'Group', high_value_sets):
+ continue
+ group_upper = group.upper()
+ if 'DOMAIN USERS' in group_upper:
+ principals.append(group)
has_broad = True
- elif 'DOMAIN COMPUTERS' in group.upper():
- who.append(f"{group} (all domain computers)")
+ elif 'DOMAIN COMPUTERS' in group_upper:
+ principals.append(group)
has_broad = True
- elif 'AUTHENTICATED USERS' in group.upper():
- who.append(f"{group} (all authenticated users)")
+ elif 'AUTHENTICATED USERS' in group_upper:
+ principals.append(group)
has_broad = True
else:
- who.append(group)
+ principals.append(group)
if not has_broad:
- users = logins.get('users', [])
- if users:
- shown = users[:5]
- if len(users) > 5:
- who.append(f"{', '.join(shown)} (+{len(users)-5} more users)")
- else:
- who.append(', '.join(shown))
-
- computers = logins.get('computers', [])
- if computers:
- shown = computers[:3]
- if len(computers) > 3:
- who.append(f"{', '.join(shown)} (+{len(computers)-3} more computers)")
- else:
- who.append(', '.join(shown))
-
- return ' | '.join(who) if who else "Users"
+ principals.extend(
+ user for user in logins.get('users', [])
+ if not self._is_filtered_start_principal(user, 'User', high_value_sets)
+ )
+ principals.extend(
+ computer for computer in logins.get('computers', [])
+ if not self._is_filtered_start_principal(computer, 'Computer', high_value_sets)
+ )
+
+ return self._dedupe(principals)
+
+ def _is_filtered_start_principal(self, name, principal_type, high_value_sets):
+ if not high_value_sets:
+ return False
+ users, computers, groups = high_value_sets
+ if is_high_value_principal(name, principal_type, users, computers, groups):
+ return True
+ if principal_type != 'Group':
+ return is_high_value_principal(name, 'Group', users, computers, groups)
+ return False
+
+ def _dedupe(self, values):
+ seen = set()
+ index_by_key = {}
+ deduped = []
+ for value in values:
+ if not value:
+ continue
+ key = self._principal_dedupe_key(value)
+ if key in seen:
+ if self._prefer_principal_display(value, deduped[index_by_key[key]]):
+ deduped[index_by_key[key]] = value
+ continue
+ seen.add(key)
+ index_by_key[key] = len(deduped)
+ deduped.append(value)
+ return deduped
+
+ def _principal_dedupe_key(self, value):
+ upper = value.upper()
+ account = upper.split('@', 1)[0]
+ domain = None
+
+ if '@' in upper:
+ _account, domain = upper.split('@', 1)
+ elif '\\' in account:
+ domain, account = account.split('\\', 1)
+
+ if account in _COMMON_GROUP_NAMES:
+ return f"GROUP:{self._normalize_domain_key(domain)}:{account}"
+
+ return f"PRINCIPAL:{self._normalize_domain_key(domain)}:{account}"
+
+ def _normalize_domain_key(self, domain):
+ if not domain:
+ return self._domain_filter.upper() if self._domain_filter else ""
+ if '.' in domain:
+ return domain
+ if self._domain_filter and self._domain_filter.upper().startswith(domain + '.'):
+ return self._domain_filter.upper()
+ return domain
+
+ def _prefer_principal_display(self, candidate, current):
+ candidate_upper = candidate.upper()
+ current_upper = current.upper()
+ return '@' in candidate_upper and '@' not in current_upper
diff --git a/checks/mssql_privilege_escalation.py b/checks/mssql_privilege_escalation.py
index ff945e5..5aa48df 100644
--- a/checks/mssql_privilege_escalation.py
+++ b/checks/mssql_privilege_escalation.py
@@ -1,4 +1,14 @@
from checks.core import Check, check
+from checks.core.high_value import high_value_principal_sets_for_check, is_high_value_principal
+from checks.core.mssql_common import (
+ MSSQL_ABUSE_EDGES,
+ host_sid_resolves_to_single_server,
+ linked_server_target_resolution,
+ primary_label_expr,
+ sql_login_holder_parameters,
+ sql_login_report_holder_expr,
+ sql_login_report_holder_filter,
+)
_INTERNAL_MSSQL_OBJECTS = {
'##MS_POLICYSIGNINGCERTIFICATE##',
@@ -33,7 +43,7 @@
_VALUABLE_NODE_TYPES = {
'MSSQL_ServerRole', 'MSSQL_Login', 'MSSQL_DatabaseRole',
- 'MSSQL_DatabaseUser', 'MSSQL_Server', 'Computer',
+ 'MSSQL_DatabaseUser',
}
_SYSADMIN_SUBORDINATES = {
@@ -41,13 +51,17 @@
'BULKADMIN', 'DISKADMIN', 'DBCREATOR',
}
-@check(risk="High", category="MSSQL Privilege Escalation", entity="user", data=[], requires=["mssql"])
+@check(risk="High", category="MSSQL Privilege Escalation", entity="user", data=[], requires=["mssql"],
+ display="shared_graph_paths")
class MSSQLPrivilegeEscalationCheck(Check):
def execute(self):
- admin_users, admin_computers = self.neo4j_data.get_admin_users_and_computers()
- admin_users_set = set(u.upper() for u in admin_users) if admin_users else set()
- admin_computers_set = set(c.upper() for c in admin_computers) if admin_computers else set()
+ high_value_users, high_value_computers, high_value_groups = (
+ high_value_principal_sets_for_check(
+ self,
+ query_name="mssql_privilege_escalation_high_value_principals",
+ )
+ )
paths_data = self._fetch_escalation_paths()
if not paths_data:
@@ -55,8 +69,13 @@ def execute(self):
results = {}
for entity_sid, entity_info in paths_data.items():
- entity_name = (entity_info.get('name') or '').upper()
- if entity_name in admin_users_set or entity_name in admin_computers_set:
+ if is_high_value_principal(
+ entity_info.get('name') or '',
+ entity_info.get('principal_type') or '',
+ high_value_users,
+ high_value_computers,
+ high_value_groups,
+ ):
continue
if not entity_info.get('paths'):
continue
@@ -114,10 +133,13 @@ def execute(self):
def _fetch_escalation_paths(self):
domain_cond = self._domain_condition("principal")
+ holder_domain_cond = self._domain_condition("holder")
+ candidate_domain_cond = self._domain_condition("candidatePrincipal")
+ member_domain_cond = self._domain_condition("memberPrincipal")
rows = self.query(f"""
- MATCH (startLogin:MSSQL_Login)<-[:MSSQL_HasLogin]-(principal)
- WHERE (principal:User OR principal:Computer){domain_cond}
+ MATCH (holder)-[:MSSQL_HasLogin]->(startLogin:MSSQL_Login)
+ WHERE holder:User OR holder:Computer OR holder:Group
MATCH (target)
WHERE (
@@ -138,44 +160,68 @@ def _fetch_escalation_paths(self):
'CONTROL', 'ALTER', 'ALTER ANY DATABASE ROLE',
'ALTER ANY USER', 'IMPERSONATE', 'TAKE OWNERSHIP'
]))
- OR (target:Computer)
)
AND target <> startLogin
- WITH principal, startLogin, target
+ WITH holder, startLogin, target,
+ {sql_login_report_holder_expr()} AS reportHolder
+ {sql_login_report_holder_filter(holder_domain_cond, candidate_domain_cond)}
MATCH p = shortestPath((startLogin)-[rels*1..5]->(target))
- WHERE ALL(rel IN rels WHERE rel.traversable = true)
+ WHERE ALL(rel IN rels WHERE type(rel) IN $_abuse_edges)
OPTIONAL MATCH (db:MSSQL_Database)-[:MSSQL_Contains]->(target)
- OPTIONAL MATCH (server:MSSQL_Server {{name: target.SQLServer}})
-
- WITH principal, startLogin, target, p, rels, db, server,
- [node in nodes(p) | coalesce(node.name, '')] as pathNodes,
- [rel in rels | type(rel)] as pathEdges,
- length(p) as pathLength,
- CASE WHEN principal:Computer THEN 'Computer' ELSE 'User' END as principalType
+ OPTIONAL MATCH memberPath = (memberPrincipal)-[:MemberOf*1..6]->(holder)
+ WHERE holder:Group AND NOT reportHolder
+ AND (memberPrincipal:User OR memberPrincipal:Computer){member_domain_cond}
+
+ WITH CASE
+ WHEN reportHolder OR holder:User OR holder:Computer THEN holder
+ ELSE memberPrincipal
+ END AS principal,
+ holder, startLogin, target, p, rels, db, reportHolder, memberPath
+ WHERE principal IS NOT NULL
+ AND (reportHolder OR principal:User OR principal:Computer){domain_cond}
+ AND (NOT principal:Computer OR coalesce(principal.enabled, true) = true)
+
+ WITH principal, holder, startLogin, target, p, rels, db, reportHolder, memberPath,
+ CASE
+ WHEN reportHolder THEN [split(coalesce(holder.name, ''), '@')[0]] + [node IN nodes(p) | coalesce(node.name, '')]
+ WHEN holder:Group AND memberPath IS NOT NULL
+ THEN {self._ad_member_path_node_names_expr('nodes(memberPath)')} + [node IN nodes(p) | coalesce(node.name, '')]
+ ELSE [node in nodes(p) | coalesce(node.name, '')]
+ END as pathNodes,
+ CASE
+ WHEN reportHolder THEN ['MSSQL_HasLogin'] + [rel in rels | type(rel)]
+ WHEN holder:Group AND memberPath IS NOT NULL
+ THEN [rel IN relationships(memberPath) | type(rel)] + ['MSSQL_HasLogin'] + [rel in rels | type(rel)]
+ ELSE [rel in rels | type(rel)]
+ END as pathEdges,
+ CASE
+ WHEN reportHolder THEN length(p) + 1
+ WHEN holder:Group AND memberPath IS NOT NULL THEN length(memberPath) + length(p) + 1
+ ELSE length(p)
+ END as pathLength,
+ CASE
+ WHEN reportHolder THEN 'Group'
+ WHEN principal:Computer THEN 'Computer'
+ ELSE 'User'
+ END as principalType
- WITH principal, startLogin, target, p, rels, db, server, pathNodes, pathEdges, pathLength, principalType,
+ WITH principal, holder, startLogin, target, p, rels, db, reportHolder,
+ pathNodes, pathEdges, pathLength, principalType,
CASE
WHEN target:MSSQL_ServerRole THEN 'MSSQL_ServerRole'
WHEN target:MSSQL_DatabaseRole THEN 'MSSQL_DatabaseRole'
WHEN target:MSSQL_Login THEN 'MSSQL_Login'
WHEN target:MSSQL_DatabaseUser THEN 'MSSQL_DatabaseUser'
- WHEN target:MSSQL_Server THEN 'MSSQL_Server'
WHEN target:MSSQL_Database THEN 'MSSQL_Database'
- WHEN target:Computer THEN 'Computer'
WHEN target:MSSQL_Base THEN 'MSSQL'
WHEN target:SCCM_Base THEN 'SCCM'
- ELSE coalesce(
- head([label IN labels(target)
- WHERE NOT label IN ['Base', 'MSSQL_Base', 'SCCM_Base', 'OpenGraph_Stub', 'ADLocalGroup', 'LocalGroup']
- AND NOT label STARTS WITH 'Tag_']),
- 'Unknown'
- )
+ ELSE coalesce({primary_label_expr('target')}, 'Unknown')
END as targetTypeResolved
- RETURN principal.objectid as userSid,
- principal.name as userName,
+ RETURN CASE WHEN reportHolder THEN holder.objectid ELSE principal.objectid END as userSid,
+ CASE WHEN reportHolder THEN holder.name ELSE principal.name END as userName,
principalType,
startLogin.name as loginName,
startLogin.SQLServer as sqlServer,
@@ -186,12 +232,16 @@ def _fetch_escalation_paths(self):
db.name as databaseName,
db.isTrustworthy as isTrustworthy,
db.hasGuestEnabled as hasGuestEnabled,
- server.xpCmdShellEnabled as xpCmdShellEnabled,
pathNodes,
pathEdges,
pathLength
ORDER BY userName, pathLength
- """, name="mssql_privilege_escalation_paths")
+ """, parameters={
+ "_abuse_edges": MSSQL_ABUSE_EDGES,
+ **sql_login_holder_parameters(),
+ }, name="mssql_privilege_escalation_paths")
+
+ rows = list(rows) + self._fetch_linked_server_paths()
paths_by_user = {}
for row in rows:
@@ -216,7 +266,6 @@ def _fetch_escalation_paths(self):
'database_name': row.get('databaseName') or '',
'is_trustworthy': row.get('isTrustworthy', False),
'has_guest_enabled': row.get('hasGuestEnabled', False),
- 'xp_cmdshell_enabled': row.get('xpCmdShellEnabled', False),
'path_nodes': row.get('pathNodes') or [],
'path_edges': row.get('pathEdges') or [],
'path_length': row.get('pathLength') or 0,
@@ -224,6 +273,77 @@ def _fetch_escalation_paths(self):
return paths_by_user
+ def _fetch_linked_server_paths(self):
+ domain_cond = self._domain_condition("principal")
+ holder_domain_cond = self._domain_condition("holder")
+ candidate_domain_cond = self._domain_condition("candidatePrincipal")
+ member_domain_cond = self._domain_condition("memberPrincipal")
+
+ rows = self.query(f"""
+ MATCH (holder)-[:MSSQL_HasLogin]->(startLogin:MSSQL_Login)
+ WHERE holder:User OR holder:Computer OR holder:Group
+
+ WITH holder, startLogin,
+ {sql_login_report_holder_expr()} AS reportHolder
+ {sql_login_report_holder_filter(holder_domain_cond, candidate_domain_cond)}
+ MATCH (startLogin)-[:MSSQL_Connect]->(srvA:MSSQL_Server)
+ MATCH (stub:MSSQL_Base)-[:MSSQL_LinkedAsAdmin]->(srvB_stub:MSSQL_Server)
+ WHERE toLower(stub.name) ENDS WITH toLower(':' + srvA.sqlServerName)
+ OR (stub.objectid IS NOT NULL AND srvA.objectid IS NOT NULL
+ AND stub.objectid CONTAINS ':' AND srvA.objectid CONTAINS ':'
+ AND split(stub.objectid, ':')[0] = split(srvA.objectid, ':')[0]
+ AND {host_sid_resolves_to_single_server("split(srvA.objectid, ':')[0]")})
+
+ MATCH (srvB:MSSQL_Server)-[:MSSQL_Contains]->(target:MSSQL_ServerRole)
+ WHERE {linked_server_target_resolution('srvB', 'srvB_stub')}
+ AND toUpper(target.name) IN [
+ 'SYSADMIN', 'SECURITYADMIN', 'SERVERADMIN', 'PROCESSADMIN',
+ 'SETUPADMIN', 'BULKADMIN', 'DISKADMIN', 'DBCREATOR'
+ ]
+ OPTIONAL MATCH memberPath = (memberPrincipal)-[:MemberOf*1..6]->(holder)
+ WHERE holder:Group AND NOT reportHolder
+ AND (memberPrincipal:User OR memberPrincipal:Computer){member_domain_cond}
+ WITH CASE
+ WHEN reportHolder OR holder:User OR holder:Computer THEN holder
+ ELSE memberPrincipal
+ END AS principal,
+ holder, startLogin, reportHolder, srvA, srvB, target, memberPath
+ WHERE principal IS NOT NULL
+ AND (reportHolder OR principal:User OR principal:Computer){domain_cond}
+ AND (NOT principal:Computer OR coalesce(principal.enabled, true) = true)
+ RETURN DISTINCT
+ CASE WHEN reportHolder THEN holder.objectid ELSE principal.objectid END as userSid,
+ CASE WHEN reportHolder THEN holder.name ELSE principal.name END as userName,
+ CASE
+ WHEN reportHolder THEN 'Group'
+ WHEN principal:Computer THEN 'Computer'
+ ELSE 'User'
+ END as principalType,
+ startLogin.name as loginName,
+ startLogin.SQLServer as sqlServer,
+ target.name as targetName,
+ 'MSSQL_ServerRole' as targetType,
+ srvB.name as targetServer,
+ CASE
+ WHEN reportHolder THEN [split(coalesce(holder.name, ''), '@')[0], startLogin.name, srvA.name, srvB.name, target.name]
+ WHEN holder:Group AND memberPath IS NOT NULL
+ THEN {self._ad_member_path_node_names_expr('nodes(memberPath)')} + [startLogin.name, srvA.name, srvB.name, target.name]
+ ELSE [startLogin.name, srvA.name, srvB.name, target.name]
+ END as pathNodes,
+ CASE
+ WHEN reportHolder THEN ['MSSQL_HasLogin', 'MSSQL_Connect', 'MSSQL_LinkedAsAdmin', 'MSSQL_Contains']
+ WHEN holder:Group AND memberPath IS NOT NULL
+ THEN [rel IN relationships(memberPath) | type(rel)] + ['MSSQL_HasLogin', 'MSSQL_Connect', 'MSSQL_LinkedAsAdmin', 'MSSQL_Contains']
+ ELSE ['MSSQL_Connect', 'MSSQL_LinkedAsAdmin', 'MSSQL_Contains']
+ END as pathEdges,
+ CASE
+ WHEN reportHolder THEN 4
+ WHEN holder:Group AND memberPath IS NOT NULL THEN length(memberPath) + 4
+ ELSE 3
+ END as pathLength
+ """, parameters=sql_login_holder_parameters(), name="mssql_priv_esc_linked_server")
+ return list(rows)
+
def _normalize_server_name(self, server_name):
if not server_name:
return ''
@@ -240,42 +360,36 @@ def _build_chain(self, path_info, server_display=''):
return ""
cleaned = [self._clean_node_name(n) for n in path_nodes]
-
- has_oscmd_tail = (
- target_type == 'Computer'
- and len(path_edges) >= 2
- and path_edges[-2] == 'MSSQL_ControlServer'
- and path_edges[-1] == 'MSSQL_ExecuteOnHost'
- )
+ if server_display and cleaned:
+ cleaned[-1] = self._target_display_name(cleaned[-1], target_type, server_display)
parts = [cleaned[0]]
for i, edge in enumerate(path_edges):
next_name = cleaned[i + 1] if i + 1 < len(cleaned) else '?'
parts.append(edge)
- is_final_host = has_oscmd_tail and i == len(path_edges) - 1
- parts.append(next_name.lower() if is_final_host else self._display_node(next_name))
+ parts.append(self._display_node(next_name))
chain = " -> ".join(parts)
- if has_oscmd_tail:
- return chain
-
if target_type in ('MSSQL_DatabaseRole', 'MSSQL_DatabaseUser') and database_name:
chain = chain + f" ({database_name})"
elif target_type == 'MSSQL_Login':
perms = [p for p in target_permissions if p and p.upper() in _HIGH_VALUE_PERMISSIONS]
if perms:
chain = chain + f" ({', '.join(perms)})"
- if server_display:
- chain = chain + f" on {server_display}"
return chain
+ def _target_display_name(self, name, target_type, server_display):
+ if target_type in ('MSSQL_ServerRole', 'MSSQL_Login') and '@' not in name:
+ return f"{name}@{server_display}"
+ return name
+
def _display_node(self, name):
- upper = name.upper()
- if upper in _HIGH_VALUE_SERVER_ROLES or upper in _HIGH_VALUE_DB_ROLES:
- return name.lower()
+ role_name, sep, scope = name.partition('@')
+ if role_name.upper() in _HIGH_VALUE_SERVER_ROLES or role_name.upper() in _HIGH_VALUE_DB_ROLES:
+ return f"{role_name.lower()}{sep}{scope}"
return name
def _is_valuable_target(self, path_info):
@@ -304,3 +418,6 @@ def _clean_node_name(self, node_name):
node_name = node_name.split('\\')[-1]
node_name = self._normalize_server_name(node_name)
return node_name
+
+ def _ad_member_path_node_names_expr(self, nodes_expr):
+ return f"[node IN {nodes_expr} | split(coalesce(node.name, node.objectid, ''), '@')[0]]"
diff --git a/checks/mssql_sccm.py b/checks/mssql_sccm.py
deleted file mode 100644
index 22ab4ec..0000000
--- a/checks/mssql_sccm.py
+++ /dev/null
@@ -1,328 +0,0 @@
-from checks.core import Check, check
-from checks.core.platform_mixins import MSSQLDomainMixin
-
-
-@check(risk="Critical", category="SCCM Database Compromise", entity="user", data=[], requires=["mssql"])
-class MSSQLSCCMCheck(MSSQLDomainMixin, Check):
-
- def execute(self):
- admin_users, admin_computers = self.neo4j_data.get_admin_users_and_computers()
- admin_users_set = set(u.upper() for u in admin_users) if admin_users else set()
- admin_computers_set = set(c.upper() for c in admin_computers) if admin_computers else set()
-
- sccm_paths = self._fetch_sccm_paths()
- if not sccm_paths:
- return {}
-
- by_principal = {}
-
- for path in sccm_paths:
- sid = path.get('principal_sid')
- if not sid:
- continue
-
- name = (path.get('principal_name') or '').upper()
- ptype = path.get('principal_type') or ''
-
- attack_type = path.get('attack_type') or ''
-
- if attack_type == 'privilege_escalation':
- if ptype == 'User' and name in admin_users_set:
- continue
- if ptype == 'Computer' and name in admin_computers_set:
- continue
-
- if sid not in by_principal:
- by_principal[sid] = {'name': name, 'type': ptype, 'paths': []}
- by_principal[sid]['paths'].append(path)
-
- results = {}
- for sid, info in by_principal.items():
- lines = self._format_principal(info)
- if lines:
- results[sid] = self.finding("\n".join(lines))
-
- return results
-
- def _fetch_sccm_paths(self):
- all_paths = []
- all_paths.extend(self._fetch_priv_esc_paths())
- all_paths.extend(self._fetch_coercion_paths())
- all_paths.extend(self._fetch_service_account_paths())
- return all_paths
-
- def _fetch_priv_esc_paths(self):
- domain_cond = self._domain_condition("principal")
-
- rows = self.query(f"""
- MATCH (principal)-[:MSSQL_HasLogin]->(startLogin:MSSQL_Login)
- WHERE (principal:User OR principal:Computer){domain_cond}
-
- WITH principal, startLogin
- MATCH (sccmDb:MSSQL_Database)
- WHERE sccmDb.isTrustworthy = true
- AND (sccmDb.name CONTAINS 'CM_' OR sccmDb.name CONTAINS 'SMS' OR sccmDb.name CONTAINS 'SCCM')
-
- WITH principal, startLogin, sccmDb
- MATCH (target)
- WHERE (
- (target:MSSQL_DatabaseRole
- AND target.name IN ['db_owner', 'db_securityadmin', 'db_accessadmin', 'db_ddladmin']
- AND (sccmDb)-[:MSSQL_Contains]->(target))
- OR (target:MSSQL_DatabaseUser
- AND target.explicitPermissions IS NOT NULL
- AND ('CONTROL' IN target.explicitPermissions OR 'ALTER DATABASE' IN target.explicitPermissions)
- AND (sccmDb)-[:MSSQL_Contains]->(target))
- OR (target = sccmDb)
- OR (target:SCCM_Site AND (sccmDb)-[:SCCM_AssignAllPermissions]->(target))
- )
-
- WITH principal, startLogin, target, sccmDb
- MATCH p = shortestPath((startLogin)-[rels*1..5]->(target))
- WHERE ALL(rel IN rels WHERE rel.traversable = true)
- AND length(p) > 0
-
- WITH principal, startLogin, target, sccmDb, p, nodes(p) as pathNodes,
- [rel in rels | type(rel)] as pathEdges,
- length(p) as pathLength,
- CASE
- WHEN target:SCCM_Site THEN 'SCCM_Site'
- WHEN target:MSSQL_Database THEN 'MSSQL_Database'
- WHEN target:MSSQL_DatabaseUser THEN 'MSSQL_DatabaseUser'
- WHEN target:MSSQL_DatabaseRole THEN 'MSSQL_DatabaseRole'
- WHEN target:MSSQL_Base THEN 'MSSQL'
- WHEN target:SCCM_Base THEN 'SCCM'
- ELSE coalesce(
- head([label IN labels(target)
- WHERE NOT label IN ['Base', 'MSSQL_Base', 'SCCM_Base', 'OpenGraph_Stub', 'ADLocalGroup', 'LocalGroup']
- AND NOT label STARTS WITH 'Tag_']),
- 'Unknown'
- )
- END as targetType
-
- WITH principal, startLogin, target, sccmDb, p, pathNodes, pathEdges, pathLength, targetType
- WHERE ANY(node IN pathNodes WHERE
- (node = sccmDb) OR
- (node:MSSQL_DatabaseUser AND node.database = sccmDb.name) OR
- (node:MSSQL_DatabaseRole AND node.database = sccmDb.name)
- )
-
- OPTIONAL MATCH (server:MSSQL_Server {{name: sccmDb.SQLServer}})
-
- RETURN DISTINCT
- principal.objectid as principalSid,
- principal.name as principalName,
- CASE WHEN principal:Computer THEN 'Computer' ELSE 'User' END as principalType,
- target.name as targetName,
- targetType,
- target.explicitPermissions as targetPermissions,
- sccmDb.name as databaseName,
- sccmDb.SQLServer as serverName,
- server.xpCmdShellEnabled as xpCmdShellEnabled,
- pathEdges,
- pathLength,
- [node IN pathNodes | coalesce(
- node.name,
- head([label IN labels(node)
- WHERE NOT label IN ['Base', 'MSSQL_Base', 'SCCM_Base', 'OpenGraph_Stub', 'ADLocalGroup', 'LocalGroup']
- AND NOT label STARTS WITH 'Tag_']),
- 'Unknown'
- )] as pathNodeNames
- """, name="sccm_privilege_escalation_paths")
-
- paths = []
- for row in rows:
- paths.append({
- 'attack_type': 'privilege_escalation',
- 'principal_sid': row.get('principalSid'),
- 'principal_name': row.get('principalName') or '',
- 'principal_type': row.get('principalType') or '',
- 'target_name': row.get('targetName') or '',
- 'target_type': row.get('targetType') or '',
- 'target_permissions': row.get('targetPermissions') or [],
- 'database_name': row.get('databaseName') or '',
- 'server_name': row.get('serverName') or '',
- 'xp_cmdshell_enabled': row.get('xpCmdShellEnabled', False),
- 'path_edges': row.get('pathEdges') or [],
- 'path_length': row.get('pathLength') or 0,
- 'path_node_names': row.get('pathNodeNames') or [],
- })
- return paths
-
- def _fetch_coercion_paths(self):
- source_domain_cond = self._domain_condition("source")
- source_filter = (f"\nWHERE true{source_domain_cond}") if source_domain_cond else ""
-
- rows = self.query(f"""
- MATCH (source)-[:CoerceAndRelayToMSSQL]->(login:MSSQL_Login){source_filter}
-
- WITH source, login
- MATCH (sccmDb:MSSQL_Database {{SQLServer: login.SQLServer}})
- WHERE sccmDb.isTrustworthy = true
- AND (sccmDb.name CONTAINS 'CM_' OR sccmDb.name CONTAINS 'SMS' OR sccmDb.name CONTAINS 'SCCM')
-
- OPTIONAL MATCH (server:MSSQL_Server {{name: login.SQLServer}})
-
- RETURN DISTINCT
- CASE
- WHEN source:Computer THEN 'Computer'
- WHEN source:User THEN 'User'
- WHEN source:MSSQL_Base THEN 'MSSQL'
- WHEN source:SCCM_Base THEN 'SCCM'
- ELSE coalesce(
- head([label IN labels(source)
- WHERE NOT label IN ['Base', 'MSSQL_Base', 'SCCM_Base', 'OpenGraph_Stub', 'ADLocalGroup', 'LocalGroup']
- AND NOT label STARTS WITH 'Tag_']),
- 'Unknown'
- )
- END as sourceType,
- source.name as sourceName,
- source.objectid as sourceSid,
- login.name as loginName,
- login.SQLServer as sqlServer,
- server.xpCmdShellEnabled as xpCmdShellEnabled,
- collect(DISTINCT sccmDb.name) as sccmDatabases
- """, name="sccm_coercion_paths")
-
- paths = []
- for row in rows:
- paths.append({
- 'attack_type': 'coercion',
- 'principal_sid': row.get('sourceSid'),
- 'principal_name': row.get('sourceName') or '',
- 'principal_type': row.get('sourceType') or '',
- 'target_name': row.get('loginName') or '',
- 'target_type': 'MSSQL_Login',
- 'database_name': ', '.join(row.get('sccmDatabases') or []),
- 'server_name': row.get('sqlServer') or '',
- 'xp_cmdshell_enabled': row.get('xpCmdShellEnabled', False),
- 'path_edges': ['CoerceAndRelayToMSSQL'],
- 'path_length': 1,
- })
- return paths
-
- def _fetch_service_account_paths(self):
- sccm_server_filter = self._server_domain_condition("sccmDb.SQLServer")
-
- rows = self.query(f"""
- MATCH (sccmDb:MSSQL_Database)
- WHERE sccmDb.isTrustworthy = true
- AND (sccmDb.name CONTAINS 'CM_' OR sccmDb.name CONTAINS 'SMS' OR sccmDb.name CONTAINS 'SCCM'){sccm_server_filter}
-
- WITH DISTINCT sccmDb.SQLServer as sqlServer, collect(DISTINCT sccmDb.name) as sccmDatabases
-
- MATCH (serviceAccount)-[:MSSQL_ServiceAccountFor]->(server:MSSQL_Server {{name: sqlServer}})
-
- RETURN DISTINCT
- CASE
- WHEN serviceAccount:Computer THEN 'Computer'
- WHEN serviceAccount:User THEN 'User'
- WHEN serviceAccount:MSSQL_Base THEN 'MSSQL'
- WHEN serviceAccount:SCCM_Base THEN 'SCCM'
- ELSE coalesce(
- head([label IN labels(serviceAccount)
- WHERE NOT label IN ['Base', 'MSSQL_Base', 'SCCM_Base', 'OpenGraph_Stub', 'ADLocalGroup', 'LocalGroup']
- AND NOT label STARTS WITH 'Tag_']),
- 'Unknown'
- )
- END as serviceAccountType,
- serviceAccount.name as serviceAccountName,
- serviceAccount.objectid as serviceAccountSid,
- sqlServer,
- sccmDatabases,
- server.xpCmdShellEnabled as xpCmdShellEnabled
- """, name="sccm_service_account_paths")
-
- paths = []
- for row in rows:
- paths.append({
- 'attack_type': 'service_account',
- 'principal_sid': row.get('serviceAccountSid'),
- 'principal_name': row.get('serviceAccountName') or '',
- 'principal_type': row.get('serviceAccountType') or '',
- 'target_name': 'SQL Server Service Account',
- 'target_type': 'MSSQL_Server',
- 'database_name': ', '.join(row.get('sccmDatabases') or []),
- 'server_name': row.get('sqlServer') or '',
- 'xp_cmdshell_enabled': row.get('xpCmdShellEnabled', False),
- 'path_edges': ['MSSQL_ServiceAccountFor'],
- 'path_length': 1,
- })
- return paths
-
- def _format_principal(self, info):
- paths = info['paths']
- seen = set()
- deduped = []
-
- for path in paths:
- attack = path.get('attack_type', '')
- dedup = (attack, path.get('target_name', ''), tuple(path.get('path_edges', [])))
- if dedup in seen:
- continue
- seen.add(dedup)
- deduped.append((attack, path))
-
- deduped.sort(key=lambda x: (
- 0 if x[0] == 'privilege_escalation' else 1 if x[0] == 'coercion' else 2,
- x[1].get('path_length', 0)
- ))
-
- lines = []
- for attack, path in deduped:
- line = self._format_path_line(attack, path)
- if line:
- lines.append(line)
- return lines
-
- def _format_path_line(self, attack_type, path):
- server = (path.get('server_name') or '').split(':')[0]
- server_display = server.lower() if server else 'unknown'
- xp = path.get('xp_cmdshell_enabled', False)
- rce_suffix = "xp_cmdshell ENABLED" if xp else "can enable xp_cmdshell"
-
- if attack_type == 'privilege_escalation':
- chain = self._build_chain(path)
- target_type = path.get('target_type', '')
-
- if target_type == 'SCCM_Site':
- impact = "RCE on all managed clients"
- else:
- impact = f"{rce_suffix} on {server_display}"
-
- return f"{chain} -> {impact}" if chain else impact
-
- elif attack_type == 'coercion':
- target_login = self._clean_name(path.get('target_name', '') or 'unknown login')
- principal_name = self._clean_name(path.get('principal_name', '') or 'principal')
- return f"{principal_name} -> CoerceAndRelayToMSSQL -> {target_login} -> {rce_suffix} on {server_display}"
-
- elif attack_type == 'service_account':
- principal_name = self._clean_name(path.get('principal_name', '') or 'principal')
- return f"{principal_name} -> MSSQL_ServiceAccountFor -> {server_display} -> {rce_suffix}"
-
- return ""
-
- def _build_chain(self, path):
- edges = path.get('path_edges', [])
- nodes = path.get('path_node_names', [])
-
- if not edges or not nodes:
- return ""
-
- parts = [self._clean_name(nodes[0])]
- for i, edge in enumerate(edges):
- next_name = self._clean_name(nodes[i + 1]) if i + 1 < len(nodes) else "?"
- parts.append(edge)
- parts.append(next_name)
-
- return " -> ".join(parts)
-
- def _clean_name(self, name):
- if not name:
- return "?"
- if '\\' in name:
- name = name.split('\\')[-1]
- if ':' in name:
- name = name.split(':')[0]
- return name
diff --git a/checks/sccm_privilege_escalation.py b/checks/sccm_privilege_escalation.py
new file mode 100644
index 0000000..4f0b95d
--- /dev/null
+++ b/checks/sccm_privilege_escalation.py
@@ -0,0 +1,962 @@
+from checks.core import Check, check
+from checks.core.high_value import high_value_principal_sets_for_check, is_high_value_principal
+from checks.core.mssql_common import (
+ MSSQL_ABUSE_EDGES,
+ host_sid_resolves_to_single_server,
+ linked_server_target_resolution,
+ primary_label_expr,
+ sql_login_holder_parameters,
+ sql_login_report_holder_expr,
+ sql_login_report_holder_filter,
+)
+from checks.core.platform_mixins import MSSQLDomainMixin, SCCMDomainMixin
+
+
+_SCCM_PATH_EDGES = [
+ 'MemberOf', *MSSQL_ABUSE_EDGES,
+ 'SCCM_IsMappedTo', 'SCCM_AllPermissions', 'SCCM_IsAssigned',
+]
+
+_LINKED_PRE_EDGES = [
+ 'MSSQL_ExecuteAs', 'MSSQL_Impersonate',
+ 'MSSQL_Connect', 'MSSQL_ConnectAnyDatabase',
+]
+
+_SERVER_REACH_EDGES = [
+ 'MSSQL_Connect', 'MSSQL_ConnectAnyDatabase',
+]
+
+_ASSUME_ROLE_EDGES = [
+ 'MSSQL_ExecuteAs', 'MSSQL_Impersonate',
+]
+
+_SERVER_CONTROL_EDGES = [
+ 'MSSQL_ControlServer', 'MSSQL_AlterAnyLogin',
+ 'MSSQL_AlterAnyServerRole', 'MSSQL_GrantAnyPermission',
+ 'MSSQL_ImpersonateAnyLogin', 'MSSQL_ExecuteAsOwner',
+]
+
+_HIGH_VALUE_SERVER_ROLES = [
+ 'SYSADMIN', 'SECURITYADMIN', 'SERVERADMIN', 'SETUPADMIN',
+]
+
+_HIGH_VALUE_DB_ROLES = [
+ 'db_owner', 'db_securityadmin', 'db_accessadmin', 'db_ddladmin',
+]
+
+_HIGH_VALUE_DB_PERMS = [
+ 'CONTROL', 'ALTER DATABASE',
+]
+
+_SYSADMIN_SUBORDINATES = {
+ 'SECURITYADMIN', 'SERVERADMIN', 'SETUPADMIN',
+}
+
+_DB_OWNER_SUBORDINATES = {
+ 'DB_SECURITYADMIN', 'DB_ACCESSADMIN', 'DB_DDLADMIN',
+}
+
+_PRIVILEGED_SCCM_ROLES = [
+ 'FULL ADMINISTRATOR', 'INFRASTRUCTURE ADMINISTRATOR',
+]
+
+@check(risk="Critical", category="SCCM Privilege Escalation", entity="user",
+ data=[], requires=["mssql", "sccm"], display="shared_graph_paths")
+class SCCMPrivilegeEscalationCheck(MSSQLDomainMixin, SCCMDomainMixin, Check):
+
+ def execute(self):
+ high_value_users, high_value_computers, high_value_groups = (
+ high_value_principal_sets_for_check(
+ self,
+ query_name="sccm_privilege_escalation_high_value_principals",
+ )
+ )
+
+ sccm_paths = self._fetch_sccm_paths()
+ if not sccm_paths:
+ return {}
+
+ by_principal = {}
+ for path in sccm_paths:
+ sid = path.get('principal_sid')
+ if not sid:
+ continue
+
+ name = (path.get('principal_name') or '').upper()
+ ptype = path.get('principal_type') or ''
+
+ if is_high_value_principal(
+ name,
+ ptype,
+ high_value_users,
+ high_value_computers,
+ high_value_groups,
+ ):
+ continue
+
+ by_principal.setdefault(sid, []).append(path)
+
+ results = {}
+ for sid, paths in by_principal.items():
+ lines = self._format_principal(paths)
+ if lines:
+ results[sid] = self.finding("\n".join(lines))
+
+ return results
+
+ def _fetch_sccm_paths(self):
+ return (
+ self._fetch_pure_sccm_paths()
+ + self._fetch_sql_to_sccm_paths()
+ + self._fetch_server_control_paths()
+ + self._fetch_linked_sccm_paths()
+ + self._fetch_linked_execute_host_sccm_paths()
+ + self._fetch_coercion_paths()
+ + self._fetch_service_account_paths()
+ )
+
+ def _fetch_pure_sccm_paths(self):
+ domain_cond = self._domain_condition("principal")
+ site_cond = self._site_domain_condition("site")
+
+ site_rows = self.query(f"""
+ MATCH p = (principal)-[:MemberOf*0..6]->(holder)
+ -[:SCCM_IsMappedTo]->(adminUser:SCCM_AdminUser)
+ -[:SCCM_AllPermissions]->(site:SCCM_Site)
+ WHERE (principal:User OR principal:Computer){domain_cond}
+ AND (NOT principal:Computer OR coalesce(principal.enabled, true) = true)
+ AND true{site_cond}
+ RETURN DISTINCT
+ principal.objectid AS principalSid,
+ principal.name AS principalName,
+ CASE WHEN principal:Computer THEN 'Computer' ELSE 'User' END AS principalType,
+ coalesce(site.siteCode, site.displayName, site.objectid) AS targetName,
+ 'SCCM_Site' AS targetType,
+ null AS databaseName,
+ null AS serverName,
+ [rel IN relationships(p) | type(rel)] AS pathEdges,
+ length(p) AS pathLength,
+ {self._path_node_names_expr('nodes(p)')} AS pathNodeNames,
+ null AS sccmImpactDb,
+ null AS sccmImpactSite
+ """, name="sccm_pure_site_paths")
+
+ role_rows = self.query(f"""
+ MATCH (site:SCCM_Site)
+ WHERE true{site_cond}
+ WITH collect(DISTINCT site.siteCode) AS siteCodes
+ MATCH p = (principal)-[:MemberOf*0..6]->(holder)
+ -[:SCCM_IsMappedTo]->(adminUser:SCCM_AdminUser)
+ -[:SCCM_IsAssigned]->(role:SCCM_SecurityRole)
+ WHERE (principal:User OR principal:Computer){domain_cond}
+ AND (NOT principal:Computer OR coalesce(principal.enabled, true) = true)
+ AND adminUser.sourceSiteCode IN siteCodes
+ AND toUpper(role.name) IN $_privileged_sccm_roles
+ RETURN DISTINCT
+ principal.objectid AS principalSid,
+ principal.name AS principalName,
+ CASE WHEN principal:Computer THEN 'Computer' ELSE 'User' END AS principalType,
+ coalesce(adminUser.name, adminUser.objectid) AS targetName,
+ 'SCCM_AdminUser' AS targetType,
+ null AS databaseName,
+ null AS serverName,
+ [rel IN relationships(p) | type(rel)] AS pathEdges,
+ length(p) AS pathLength,
+ {self._path_node_names_expr('nodes(p)')} AS pathNodeNames,
+ null AS sccmImpactDb,
+ null AS sccmImpactSite
+ """, parameters={
+ "_privileged_sccm_roles": _PRIVILEGED_SCCM_ROLES,
+ }, name="sccm_pure_role_paths")
+ return self._path_rows(list(site_rows) + list(role_rows))
+
+ def _fetch_sql_to_sccm_paths(self):
+ domain_cond = self._domain_condition("principal")
+ holder_domain_cond = self._domain_condition("holder")
+ candidate_domain_cond = self._domain_condition("candidatePrincipal")
+ member_domain_cond = self._domain_condition("memberPrincipal")
+ site_cond = self._site_domain_condition("site")
+ impact_site_cond = self._site_domain_condition("impactSite")
+
+ rows = self.query(f"""
+ MATCH (sccmDb:MSSQL_Database)-[:SCCM_AssignAllPermissions]->(site:SCCM_Site)
+ WHERE true{site_cond}
+ MATCH (sccmServer:MSSQL_Server {{name: sccmDb.SQLServer}})
+ WITH collect(DISTINCT sccmServer) AS sccmServers,
+ collect(DISTINCT sccmDb) AS sccmDbs
+
+ MATCH (target)
+ WHERE
+ (target:MSSQL_ServerRole
+ AND toUpper(target.name) IN $_high_value_server_roles
+ AND ANY(srv IN sccmServers WHERE target.SQLServer = srv.name))
+ OR (target:MSSQL_DatabaseRole
+ AND toLower(split(target.name, '@')[0]) IN $_high_value_db_roles
+ AND EXISTS {{
+ MATCH (db:MSSQL_Database)-[:MSSQL_Contains]->(target)
+ WHERE db IN sccmDbs
+ }})
+ OR (target:MSSQL_DatabaseUser
+ AND target.explicitPermissions IS NOT NULL
+ AND ANY(perm IN target.explicitPermissions
+ WHERE toUpper(perm) IN $_high_value_db_perms)
+ AND EXISTS {{
+ MATCH (db:MSSQL_Database)-[:MSSQL_Contains]->(target)
+ WHERE db IN sccmDbs
+ }})
+
+ MATCH (holder)-[:MSSQL_HasLogin]->(startLogin:MSSQL_Login)
+ WHERE holder:User OR holder:Computer OR holder:Group
+
+ WITH holder, startLogin, target,
+ {sql_login_report_holder_expr()} AS reportHolder
+ {sql_login_report_holder_filter(holder_domain_cond, candidate_domain_cond)}
+ MATCH p = shortestPath((startLogin)-[rels*1..6]->(target))
+ WHERE ALL(rel IN rels WHERE type(rel) IN $_sccm_path_edges)
+
+ OPTIONAL MATCH (db:MSSQL_Database)-[:MSSQL_Contains]->(target)
+ OPTIONAL MATCH (impactDb:MSSQL_Database)-[:SCCM_AssignAllPermissions]->(impactSite:SCCM_Site)
+ WHERE true{impact_site_cond}
+ AND (
+ (target:MSSQL_ServerRole AND impactDb.SQLServer = target.SQLServer)
+ OR ((target:MSSQL_DatabaseRole OR target:MSSQL_DatabaseUser)
+ AND EXISTS {{ MATCH (impactDb)-[:MSSQL_Contains]->(target) }})
+ )
+ OPTIONAL MATCH memberPath = (memberPrincipal)-[:MemberOf*1..6]->(holder)
+ WHERE holder:Group AND NOT reportHolder
+ AND (memberPrincipal:User OR memberPrincipal:Computer){member_domain_cond}
+ WITH CASE
+ WHEN reportHolder OR holder:User OR holder:Computer THEN holder
+ ELSE memberPrincipal
+ END AS principal,
+ holder, startLogin, target, db, impactDb, impactSite, p, rels, reportHolder, memberPath
+ WHERE principal IS NOT NULL
+ AND (reportHolder OR principal:User OR principal:Computer){domain_cond}
+ AND (NOT principal:Computer OR coalesce(principal.enabled, true) = true)
+
+ RETURN DISTINCT
+ CASE WHEN reportHolder THEN holder.objectid ELSE principal.objectid END AS principalSid,
+ CASE WHEN reportHolder THEN holder.name ELSE principal.name END AS principalName,
+ CASE
+ WHEN reportHolder THEN 'Group'
+ WHEN principal:Computer THEN 'Computer'
+ ELSE 'User'
+ END AS principalType,
+ target.name AS targetName,
+ CASE
+ WHEN target:MSSQL_ServerRole THEN 'MSSQL_ServerRole'
+ WHEN target:MSSQL_DatabaseRole THEN 'MSSQL_DatabaseRole'
+ WHEN target:MSSQL_DatabaseUser THEN 'MSSQL_DatabaseUser'
+ WHEN target:MSSQL_Base THEN 'MSSQL'
+ WHEN target:SCCM_Base THEN 'SCCM'
+ ELSE coalesce({primary_label_expr('target')}, 'Unknown')
+ END AS targetType,
+ db.name AS databaseName,
+ target.SQLServer AS serverName,
+ CASE
+ WHEN reportHolder THEN ['MSSQL_HasLogin'] + [rel IN rels | type(rel)]
+ WHEN holder:Group AND memberPath IS NOT NULL
+ THEN [rel IN relationships(memberPath) | type(rel)] + ['MSSQL_HasLogin'] + [rel IN rels | type(rel)]
+ ELSE [rel IN rels | type(rel)]
+ END AS pathEdges,
+ CASE
+ WHEN reportHolder THEN length(p) + 1
+ WHEN holder:Group AND memberPath IS NOT NULL THEN length(memberPath) + length(p) + 1
+ ELSE length(p)
+ END AS pathLength,
+ CASE
+ WHEN reportHolder THEN [split(coalesce(holder.name, ''), '@')[0]] + {self._path_node_names_expr('nodes(p)')}
+ WHEN holder:Group AND memberPath IS NOT NULL
+ THEN {self._path_node_names_expr('nodes(memberPath)')} + {self._path_node_names_expr('nodes(p)')}
+ ELSE {self._path_node_names_expr('nodes(p)')}
+ END AS pathNodeNames,
+ impactDb.name AS sccmImpactDb,
+ impactSite.siteCode AS sccmImpactSite
+ """, parameters={
+ "_sccm_path_edges": _SCCM_PATH_EDGES,
+ "_high_value_server_roles": _HIGH_VALUE_SERVER_ROLES,
+ "_high_value_db_roles": _HIGH_VALUE_DB_ROLES,
+ "_high_value_db_perms": _HIGH_VALUE_DB_PERMS,
+ **sql_login_holder_parameters(),
+ }, name="sccm_sql_target_paths")
+ return self._path_rows(rows)
+
+ def _fetch_server_control_paths(self):
+ domain_cond = self._domain_condition("principal")
+ holder_domain_cond = self._domain_condition("holder")
+ candidate_domain_cond = self._domain_condition("candidatePrincipal")
+ member_domain_cond = self._domain_condition("memberPrincipal")
+ site_cond = self._site_domain_condition("site")
+
+ rows = self.query(f"""
+ MATCH (holder)-[:MSSQL_HasLogin]->(startLogin:MSSQL_Login)
+ WHERE holder:User OR holder:Computer OR holder:Group
+
+ WITH holder, startLogin,
+ {sql_login_report_holder_expr()} AS reportHolder
+ {sql_login_report_holder_filter(holder_domain_cond, candidate_domain_cond)}
+ MATCH (sccmDb:MSSQL_Database)-[:SCCM_AssignAllPermissions]->(site:SCCM_Site)
+ WHERE true{site_cond}
+ MATCH (sccmServer:MSSQL_Server {{name: sccmDb.SQLServer}})
+
+ MATCH (predecessor)-[controlRel]->(sccmServer)
+ WHERE type(controlRel) IN $_server_control_edges
+
+ WITH holder, startLogin, sccmServer, sccmDb, site, predecessor, controlRel, reportHolder
+ MATCH p = shortestPath((startLogin)-[rels*0..5]->(predecessor))
+ WHERE ALL(rel IN rels WHERE type(rel) IN $_sccm_path_edges)
+
+ OPTIONAL MATCH memberPath = (memberPrincipal)-[:MemberOf*1..6]->(holder)
+ WHERE holder:Group AND NOT reportHolder
+ AND (memberPrincipal:User OR memberPrincipal:Computer){member_domain_cond}
+ WITH CASE
+ WHEN reportHolder OR holder:User OR holder:Computer THEN holder
+ ELSE memberPrincipal
+ END AS principal,
+ holder, sccmServer, sccmDb, site, p, rels, controlRel, reportHolder, memberPath
+ WHERE principal IS NOT NULL
+ AND (reportHolder OR principal:User OR principal:Computer){domain_cond}
+ AND (NOT principal:Computer OR coalesce(principal.enabled, true) = true)
+
+ WITH principal, holder, sccmServer, sccmDb, site, p, rels, controlRel, reportHolder, memberPath,
+ CASE
+ WHEN reportHolder THEN ['MSSQL_HasLogin'] + [rel IN rels | type(rel)] + [type(controlRel)]
+ WHEN holder:Group AND memberPath IS NOT NULL
+ THEN [rel IN relationships(memberPath) | type(rel)] + ['MSSQL_HasLogin'] + [rel IN rels | type(rel)] + [type(controlRel)]
+ ELSE [rel IN rels | type(rel)] + [type(controlRel)]
+ END AS pathEdges,
+ CASE
+ WHEN reportHolder THEN [split(coalesce(holder.name, ''), '@')[0]] + {self._path_node_names_expr('nodes(p) + [sccmServer]')}
+ WHEN holder:Group AND memberPath IS NOT NULL
+ THEN {self._path_node_names_expr('nodes(memberPath)')} + {self._path_node_names_expr('nodes(p) + [sccmServer]')}
+ ELSE {self._path_node_names_expr('nodes(p) + [sccmServer]')}
+ END AS pathNodeNames
+
+ RETURN DISTINCT
+ CASE WHEN reportHolder THEN holder.objectid ELSE principal.objectid END AS principalSid,
+ CASE WHEN reportHolder THEN holder.name ELSE principal.name END AS principalName,
+ CASE
+ WHEN reportHolder THEN 'Group'
+ WHEN principal:Computer THEN 'Computer'
+ ELSE 'User'
+ END AS principalType,
+ sccmServer.name AS targetName,
+ 'MSSQL_Server' AS targetType,
+ sccmDb.name AS databaseName,
+ sccmServer.name AS serverName,
+ pathEdges,
+ CASE
+ WHEN reportHolder THEN length(p) + 2
+ WHEN holder:Group AND memberPath IS NOT NULL THEN length(memberPath) + length(p) + 2
+ ELSE length(p) + 1
+ END AS pathLength,
+ pathNodeNames,
+ sccmDb.name AS sccmImpactDb,
+ site.siteCode AS sccmImpactSite
+ """, parameters={
+ "_sccm_path_edges": _SCCM_PATH_EDGES,
+ "_server_control_edges": _SERVER_CONTROL_EDGES,
+ **sql_login_holder_parameters(),
+ }, name="sccm_server_control_paths")
+ return self._path_rows(rows)
+
+ def _fetch_linked_sccm_paths(self):
+ domain_cond = self._domain_condition("principal")
+ holder_domain_cond = self._domain_condition("holder")
+ candidate_domain_cond = self._domain_condition("candidatePrincipal")
+ member_domain_cond = self._domain_condition("memberPrincipal")
+ site_cond = self._site_domain_condition("site")
+
+ rows = self.query(f"""
+ MATCH (holder)-[:MSSQL_HasLogin]->(startLogin:MSSQL_Login)
+ WHERE holder:User OR holder:Computer OR holder:Group
+
+ WITH holder, startLogin,
+ {sql_login_report_holder_expr()} AS reportHolder
+ {sql_login_report_holder_filter(holder_domain_cond, candidate_domain_cond)}
+ MATCH (startLogin)-[:MSSQL_Connect]->(srvA:MSSQL_Server)
+ MATCH (stub:MSSQL_Base)-[:MSSQL_LinkedAsAdmin]->(srvB_stub:MSSQL_Server)
+ WHERE toLower(stub.name) ENDS WITH toLower(':' + srvA.sqlServerName)
+ OR (stub.objectid IS NOT NULL AND srvA.objectid IS NOT NULL
+ AND stub.objectid CONTAINS ':' AND srvA.objectid CONTAINS ':'
+ AND split(stub.objectid, ':')[0] = split(srvA.objectid, ':')[0]
+ AND {host_sid_resolves_to_single_server("split(srvA.objectid, ':')[0]")})
+
+ MATCH (srvB:MSSQL_Server)
+ WHERE {linked_server_target_resolution('srvB', 'srvB_stub')}
+ MATCH (sccmDb:MSSQL_Database)-[:SCCM_AssignAllPermissions]->(site:SCCM_Site)
+ WHERE sccmDb.SQLServer = srvB.name{site_cond}
+
+ MATCH (target)
+ WHERE
+ (target:MSSQL_ServerRole
+ AND toUpper(target.name) IN $_high_value_server_roles
+ AND EXISTS {{ MATCH (srvB)-[:MSSQL_Contains]->(target) }})
+ OR (target:MSSQL_DatabaseRole
+ AND toLower(split(target.name, '@')[0]) IN $_high_value_db_roles
+ AND EXISTS {{ MATCH (sccmDb)-[:MSSQL_Contains]->(target) }})
+ OPTIONAL MATCH memberPath = (memberPrincipal)-[:MemberOf*1..6]->(holder)
+ WHERE holder:Group AND NOT reportHolder
+ AND (memberPrincipal:User OR memberPrincipal:Computer){member_domain_cond}
+ WITH CASE
+ WHEN reportHolder OR holder:User OR holder:Computer THEN holder
+ ELSE memberPrincipal
+ END AS principal,
+ holder, startLogin, reportHolder, srvA, srvB, sccmDb, site, target, memberPath
+ WHERE principal IS NOT NULL
+ AND (reportHolder OR principal:User OR principal:Computer){domain_cond}
+ AND (NOT principal:Computer OR coalesce(principal.enabled, true) = true)
+
+ RETURN DISTINCT
+ CASE WHEN reportHolder THEN holder.objectid ELSE principal.objectid END AS principalSid,
+ CASE WHEN reportHolder THEN holder.name ELSE principal.name END AS principalName,
+ CASE
+ WHEN reportHolder THEN 'Group'
+ WHEN principal:Computer THEN 'Computer'
+ ELSE 'User'
+ END AS principalType,
+ target.name AS targetName,
+ CASE
+ WHEN target:MSSQL_ServerRole THEN 'MSSQL_ServerRole'
+ WHEN target:MSSQL_DatabaseRole THEN 'MSSQL_DatabaseRole'
+ ELSE 'MSSQL'
+ END AS targetType,
+ CASE WHEN target:MSSQL_DatabaseRole THEN sccmDb.name ELSE null END AS databaseName,
+ srvB.name AS serverName,
+ CASE
+ WHEN target:MSSQL_DatabaseRole
+ THEN CASE
+ WHEN reportHolder THEN ['MSSQL_HasLogin', 'MSSQL_Connect', 'MSSQL_LinkedAsAdmin', 'MSSQL_Contains', 'MSSQL_Contains']
+ WHEN holder:Group AND memberPath IS NOT NULL
+ THEN [rel IN relationships(memberPath) | type(rel)] + ['MSSQL_HasLogin', 'MSSQL_Connect', 'MSSQL_LinkedAsAdmin', 'MSSQL_Contains', 'MSSQL_Contains']
+ ELSE ['MSSQL_Connect', 'MSSQL_LinkedAsAdmin', 'MSSQL_Contains', 'MSSQL_Contains']
+ END
+ ELSE CASE
+ WHEN reportHolder THEN ['MSSQL_HasLogin', 'MSSQL_Connect', 'MSSQL_LinkedAsAdmin', 'MSSQL_Contains']
+ WHEN holder:Group AND memberPath IS NOT NULL
+ THEN [rel IN relationships(memberPath) | type(rel)] + ['MSSQL_HasLogin', 'MSSQL_Connect', 'MSSQL_LinkedAsAdmin', 'MSSQL_Contains']
+ ELSE ['MSSQL_Connect', 'MSSQL_LinkedAsAdmin', 'MSSQL_Contains']
+ END
+ END AS pathEdges,
+ CASE
+ WHEN target:MSSQL_DatabaseRole THEN CASE
+ WHEN reportHolder THEN 5
+ WHEN holder:Group AND memberPath IS NOT NULL THEN length(memberPath) + 5
+ ELSE 4
+ END
+ ELSE CASE
+ WHEN reportHolder THEN 4
+ WHEN holder:Group AND memberPath IS NOT NULL THEN length(memberPath) + 4
+ ELSE 3
+ END
+ END AS pathLength,
+ CASE
+ WHEN target:MSSQL_DatabaseRole
+ THEN CASE
+ WHEN reportHolder THEN [split(coalesce(holder.name, ''), '@')[0], startLogin.name, srvA.name, srvB.name, 'MSSQL_Database(' + coalesce(sccmDb.name, 'Unknown') + ')', target.name]
+ WHEN holder:Group AND memberPath IS NOT NULL
+ THEN {self._path_node_names_expr('nodes(memberPath)')} + [startLogin.name, srvA.name, srvB.name, 'MSSQL_Database(' + coalesce(sccmDb.name, 'Unknown') + ')', target.name]
+ ELSE [startLogin.name, srvA.name, srvB.name, 'MSSQL_Database(' + coalesce(sccmDb.name, 'Unknown') + ')', target.name]
+ END
+ ELSE CASE
+ WHEN reportHolder THEN [split(coalesce(holder.name, ''), '@')[0], startLogin.name, srvA.name, srvB.name, target.name]
+ WHEN holder:Group AND memberPath IS NOT NULL
+ THEN {self._path_node_names_expr('nodes(memberPath)')} + [startLogin.name, srvA.name, srvB.name, target.name]
+ ELSE [startLogin.name, srvA.name, srvB.name, target.name]
+ END
+ END AS pathNodeNames,
+ sccmDb.name AS sccmImpactDb,
+ site.siteCode AS sccmImpactSite
+ """, parameters={
+ "_high_value_server_roles": _HIGH_VALUE_SERVER_ROLES,
+ "_high_value_db_roles": _HIGH_VALUE_DB_ROLES,
+ **sql_login_holder_parameters(),
+ }, name="sccm_linked_server_paths")
+ return self._path_rows(rows)
+
+ def _fetch_linked_execute_host_sccm_paths(self):
+ domain_cond = self._domain_condition("principal")
+ holder_domain_cond = self._domain_condition("holder")
+ candidate_domain_cond = self._domain_condition("candidatePrincipal")
+ member_domain_cond = self._domain_condition("memberPrincipal")
+ site_cond = self._site_domain_condition("site")
+
+ rows = self.query(f"""
+ MATCH (holder)-[:MSSQL_HasLogin]->(startLogin:MSSQL_Login)
+ WHERE holder:User OR holder:Computer OR holder:Group
+
+ WITH holder, startLogin,
+ {sql_login_report_holder_expr()} AS reportHolder
+ {sql_login_report_holder_filter(holder_domain_cond, candidate_domain_cond)}
+
+ MATCH pre = (startLogin)-[preR*1..5]->(src:MSSQL_Server)
+ WHERE ALL(rel IN preR WHERE type(rel) IN $_linked_pre_edges)
+ AND type(last(preR)) IN $_server_reach_edges
+ AND all(node IN nodes(pre) WHERE single(seen IN nodes(pre) WHERE seen = node))
+
+ CALL {{
+ WITH src
+ MATCH (src)-[link:MSSQL_LinkedAsAdmin|MSSQL_LinkedTo]->(linked:MSSQL_Server)
+ RETURN link, linked
+ UNION
+ WITH src
+ MATCH (stub:MSSQL_Base)-[link:MSSQL_LinkedAsAdmin|MSSQL_LinkedTo]->(linkedStub:MSSQL_Server)
+ WHERE stub.objectid IS NOT NULL
+ AND src.objectid IS NOT NULL
+ AND stub.objectid CONTAINS ':'
+ AND src.objectid CONTAINS ':'
+ AND split(stub.objectid, ':')[0] = split(src.objectid, ':')[0]
+ AND {host_sid_resolves_to_single_server("split(src.objectid, ':')[0]")}
+ MATCH (linked:MSSQL_Server)
+ WHERE linked = linkedStub
+ OR {linked_server_target_resolution('linked', 'linkedStub')}
+ RETURN link, linked
+ }}
+ WITH holder, startLogin, reportHolder, pre, preR, src, link, linked
+ WHERE (
+ type(link) = 'MSSQL_LinkedAsAdmin'
+ OR coalesce(link.remoteIsSysadmin, false) = true
+ OR coalesce(link.remoteIsSecurityAdmin, false) = true
+ OR coalesce(link.remoteHasControlServer, false) = true
+ OR coalesce(link.remoteHasImpersonateAnyLogin, false) = true
+ )
+ AND coalesce(link.rpcOut, true) = true
+
+ MATCH post = (linked)-[:MSSQL_ExecuteOnHost]->(host:Computer)
+ -[:SCCM_AssignAllPermissions]->(site:SCCM_Site)
+ WHERE coalesce(host.enabled, true) = true
+ AND true{site_cond}
+
+ WITH holder, startLogin, reportHolder, pre, preR, src, link, linked, post, host, site,
+ CASE
+ WHEN any(rel IN preR WHERE type(rel) IN $_assume_role_edges) THEN 0
+ ELSE 1
+ END AS assumeRank,
+ CASE WHEN type(link) = 'MSSQL_LinkedAsAdmin' THEN 0 ELSE 1 END AS linkRank,
+ CASE
+ WHEN any(rel IN preR WHERE type(rel) = 'MSSQL_ExecuteAs') THEN 0
+ ELSE 1
+ END AS executeAsRank
+ ORDER BY assumeRank, length(pre), linkRank, executeAsRank
+ WITH holder, startLogin, reportHolder, src, linked, host, site,
+ collect({{pre: pre, link: link, post: post}})[0] AS selected
+ WITH holder, startLogin, reportHolder, src, linked, host, site,
+ selected.pre AS pre,
+ selected.link AS link,
+ selected.post AS post
+
+ OPTIONAL MATCH (serviceAccount)-[:MSSQL_ServiceAccountFor]->(linked)
+ WITH holder, startLogin, reportHolder, src, linked, host, site, pre, link, post,
+ coalesce(serviceAccount.name, serviceAccount.objectid) AS serviceAccountName
+ ORDER BY serviceAccountName
+ WITH holder, startLogin, reportHolder, src, linked, host, site, pre, link, post,
+ [name IN collect(DISTINCT serviceAccountName) WHERE name IS NOT NULL] AS serviceAccountNames
+
+ OPTIONAL MATCH memberPath = (memberPrincipal)-[:MemberOf*1..6]->(holder)
+ WHERE holder:Group AND NOT reportHolder
+ AND (memberPrincipal:User OR memberPrincipal:Computer){member_domain_cond}
+ WITH CASE
+ WHEN reportHolder OR holder:User OR holder:Computer THEN holder
+ ELSE memberPrincipal
+ END AS principal,
+ holder, pre, link, linked, post, host, site, serviceAccountNames,
+ reportHolder, memberPath
+ WHERE principal IS NOT NULL
+ AND (reportHolder OR principal:User OR principal:Computer){domain_cond}
+ AND (NOT principal:Computer OR coalesce(principal.enabled, true) = true)
+
+ WITH principal, holder, link, linked, pre, post, host, site,
+ CASE
+ WHEN size(serviceAccountNames) > 0 THEN
+ 'as ' + reduce(
+ text = '',
+ name IN serviceAccountNames |
+ text + CASE WHEN text = '' THEN '' ELSE ', ' END + name
+ )
+ ELSE null
+ END AS executeOnHostContext,
+ reportHolder, memberPath
+
+ WITH principal, holder, link, linked, pre, post, host, site,
+ executeOnHostContext, reportHolder, memberPath,
+ [rel IN relationships(pre) | type(rel)] + [type(link)] +
+ [rel IN relationships(post) | type(rel)] AS coreEdges,
+ [rel IN relationships(pre) | null] + [null] +
+ [rel IN relationships(post) |
+ CASE
+ WHEN type(rel) = 'MSSQL_ExecuteOnHost' THEN executeOnHostContext
+ ELSE null
+ END] AS coreEdgeContexts,
+ {self._path_node_names_expr('nodes(pre)')} +
+ [coalesce(linked.name, linked.objectid, 'Unknown')] +
+ {self._path_node_names_expr('tail(nodes(post))')} AS coreNodes
+
+ RETURN DISTINCT
+ CASE WHEN reportHolder THEN holder.objectid ELSE principal.objectid END AS principalSid,
+ CASE WHEN reportHolder THEN holder.name ELSE principal.name END AS principalName,
+ CASE
+ WHEN reportHolder THEN 'Group'
+ WHEN principal:Computer THEN 'Computer'
+ ELSE 'User'
+ END AS principalType,
+ site.siteCode AS targetName,
+ 'SCCM_Site' AS targetType,
+ null AS databaseName,
+ linked.name AS serverName,
+ CASE
+ WHEN holder:Group AND memberPath IS NOT NULL
+ THEN [rel IN relationships(memberPath) | type(rel)] + ['MSSQL_HasLogin'] + coreEdges
+ ELSE ['MSSQL_HasLogin'] + coreEdges
+ END AS pathEdges,
+ CASE
+ WHEN holder:Group AND memberPath IS NOT NULL
+ THEN [rel IN relationships(memberPath) | null] + [null] + coreEdgeContexts
+ ELSE [null] + coreEdgeContexts
+ END AS pathEdgeContexts,
+ CASE
+ WHEN holder:Group AND memberPath IS NOT NULL THEN length(memberPath) + size(coreEdges) + 1
+ ELSE size(coreEdges) + 1
+ END AS pathLength,
+ CASE
+ WHEN holder:Group AND memberPath IS NOT NULL
+ THEN {self._path_node_names_expr('nodes(memberPath)')} + coreNodes
+ ELSE [split(coalesce(holder.name, ''), '@')[0]] + coreNodes
+ END AS pathNodeNames,
+ null AS sccmImpactDb,
+ site.siteCode AS sccmImpactSite
+ """, parameters={
+ "_linked_pre_edges": _LINKED_PRE_EDGES,
+ "_server_reach_edges": _SERVER_REACH_EDGES,
+ "_assume_role_edges": _ASSUME_ROLE_EDGES,
+ **sql_login_holder_parameters(),
+ }, name="sccm_linked_execute_host_paths")
+ return self._path_rows(rows)
+
+ def _fetch_coercion_paths(self):
+ source_domain_cond = self._domain_condition("source")
+ site_cond = self._site_domain_condition("site")
+
+ rows = self.query(f"""
+ MATCH (source)-[relay]->(login:MSSQL_Login)
+ WHERE type(relay) IN ['CoerceAndRelayToMSSQL', 'MSSQL_CoerceAndRelayToMSSQL']
+ AND (
+ ((source:User OR source:Computer OR source:Group){source_domain_cond})
+ OR NOT (source:User OR source:Computer OR source:Group)
+ )
+ AND (NOT source:Computer OR coalesce(source.enabled, true) = true)
+
+ WITH source, relay, login, toLower(split(login.SQLServer, ':')[0]) AS loginHost
+ MATCH (sccmDb:MSSQL_Database)-[:SCCM_AssignAllPermissions]->(site:SCCM_Site)
+ WHERE toLower(split(sccmDb.SQLServer, ':')[0]) = loginHost{site_cond}
+
+ RETURN DISTINCT
+ CASE
+ WHEN source:Group THEN 'Group'
+ WHEN source:Computer THEN 'Computer'
+ WHEN source:User THEN 'User'
+ WHEN source:MSSQL_Base THEN 'MSSQL'
+ WHEN source:SCCM_Base THEN 'SCCM'
+ ELSE coalesce({primary_label_expr('source')}, 'Unknown')
+ END AS sourceType,
+ coalesce(source.name, source.objectid) AS sourceName,
+ source.objectid AS sourceSid,
+ login.name AS loginName,
+ login.SQLServer AS sqlServer,
+ sccmDb.name AS sccmImpactDb,
+ site.siteCode AS sccmImpactSite,
+ type(relay) AS relayType
+ """, name="sccm_coercion_paths")
+
+ paths = []
+ for row in rows:
+ paths.append({
+ 'attack_type': 'coercion',
+ 'principal_sid': row.get('sourceSid'),
+ 'principal_name': row.get('sourceName') or '',
+ 'principal_type': row.get('sourceType') or '',
+ 'target_name': row.get('loginName') or '',
+ 'target_type': 'MSSQL_Login',
+ 'database_name': row.get('sccmImpactDb') or '',
+ 'server_name': row.get('sqlServer') or '',
+ 'path_edges': [row.get('relayType') or 'CoerceAndRelayToMSSQL'],
+ 'path_length': 1,
+ 'sccm_impact_db': row.get('sccmImpactDb') or '',
+ 'sccm_impact_site': row.get('sccmImpactSite') or '',
+ })
+
+ return self._prefer_ad_coercion_sources(paths)
+
+ def _fetch_service_account_paths(self):
+ site_cond = self._site_domain_condition("site")
+ service_account_domain_cond = self._domain_condition("serviceAccount")
+
+ rows = self.query(f"""
+ MATCH (sccmDb:MSSQL_Database)-[:SCCM_AssignAllPermissions]->(site:SCCM_Site)
+ WHERE true{site_cond}
+
+ WITH DISTINCT sccmDb.SQLServer AS sqlServer,
+ collect(DISTINCT sccmDb.name) AS sccmDatabases,
+ collect(DISTINCT site.siteCode) AS sccmSites
+
+ MATCH (serviceAccount)-[:MSSQL_ServiceAccountFor]->(:MSSQL_Server {{name: sqlServer}})
+ WHERE (
+ ((serviceAccount:User OR serviceAccount:Computer OR serviceAccount:Group){service_account_domain_cond})
+ OR NOT (serviceAccount:User OR serviceAccount:Computer OR serviceAccount:Group)
+ )
+ AND (NOT serviceAccount:Computer OR coalesce(serviceAccount.enabled, true) = true)
+
+ RETURN DISTINCT
+ CASE
+ WHEN serviceAccount:Group THEN 'Group'
+ WHEN serviceAccount:Computer THEN 'Computer'
+ WHEN serviceAccount:User THEN 'User'
+ WHEN serviceAccount:MSSQL_Base THEN 'MSSQL'
+ WHEN serviceAccount:SCCM_Base THEN 'SCCM'
+ ELSE coalesce({primary_label_expr('serviceAccount')}, 'Unknown')
+ END AS serviceAccountType,
+ serviceAccount.name AS serviceAccountName,
+ serviceAccount.objectid AS serviceAccountSid,
+ sqlServer,
+ sccmDatabases,
+ sccmSites
+ """, name="sccm_service_account_paths")
+
+ paths = []
+ for row in rows:
+ databases = row.get('sccmDatabases') or []
+ sites = row.get('sccmSites') or []
+ paths.append({
+ 'attack_type': 'service_account',
+ 'principal_sid': row.get('serviceAccountSid'),
+ 'principal_name': row.get('serviceAccountName') or '',
+ 'principal_type': row.get('serviceAccountType') or '',
+ 'target_name': 'SQL Server Service Account',
+ 'target_type': 'MSSQL_Server',
+ 'database_name': ', '.join(databases),
+ 'server_name': row.get('sqlServer') or '',
+ 'path_edges': ['MSSQL_ServiceAccountFor'],
+ 'path_length': 1,
+ 'sccm_impact_db': databases[0] if databases else '',
+ 'sccm_impact_site': sites[0] if sites else '',
+ })
+ return paths
+
+ def _path_rows(self, rows):
+ paths = []
+ for row in rows:
+ paths.append({
+ 'attack_type': 'privilege_escalation',
+ 'principal_sid': row.get('principalSid'),
+ 'principal_name': row.get('principalName') or '',
+ 'principal_type': row.get('principalType') or '',
+ 'target_name': row.get('targetName') or '',
+ 'target_type': row.get('targetType') or '',
+ 'database_name': row.get('databaseName') or '',
+ 'server_name': row.get('serverName') or '',
+ 'path_edges': row.get('pathEdges') or [],
+ 'path_edge_contexts': row.get('pathEdgeContexts') or [],
+ 'path_length': row.get('pathLength') or 0,
+ 'path_node_names': row.get('pathNodeNames') or [],
+ 'sccm_impact_db': row.get('sccmImpactDb') or '',
+ 'sccm_impact_site': row.get('sccmImpactSite') or '',
+ })
+ return paths
+
+ def _prefer_ad_coercion_sources(self, paths):
+ grouped = {}
+ for path in paths:
+ key = (
+ self._normalize_host(path.get('server_name') or ''),
+ path.get('sccm_impact_site') or '',
+ )
+ grouped.setdefault(key, []).append(path)
+
+ kept = []
+ for group in grouped.values():
+ ad_paths = [
+ path for path in group
+ if path.get('principal_type') in ('User', 'Computer', 'Group')
+ ]
+ if not ad_paths:
+ kept.extend(group)
+ continue
+
+ ad_names = {
+ self._clean_name(path.get('principal_name') or '').lower()
+ for path in ad_paths
+ }
+ kept.extend(
+ path for path in group
+ if path.get('principal_type') in ('User', 'Computer', 'Group')
+ or self._clean_name(path.get('principal_name') or '').lower() not in ad_names
+ )
+ return kept
+
+ def _format_principal(self, paths):
+ seen = set()
+ deduped = []
+
+ for path in paths:
+ dedup = (
+ path.get('attack_type', ''),
+ path.get('target_name', ''),
+ path.get('server_name', ''),
+ tuple(path.get('path_edges', [])),
+ path.get('sccm_impact_site', ''),
+ )
+ if dedup in seen:
+ continue
+ seen.add(dedup)
+ deduped.append(path)
+
+ deduped = self._suppress_subordinate_sql_roles(deduped)
+ deduped.sort(key=lambda path: (
+ 0 if path.get('attack_type') == 'privilege_escalation' else
+ 1 if path.get('attack_type') == 'coercion' else 2,
+ path.get('path_length', 0),
+ ))
+
+ lines = []
+ for path in deduped:
+ line = self._format_path_line(path)
+ if line:
+ lines.append(line)
+ return lines
+
+ def _suppress_subordinate_sql_roles(self, paths):
+ role_names_by_scope = {}
+ for path in paths:
+ if path.get('target_type') not in ('MSSQL_ServerRole', 'MSSQL_DatabaseRole'):
+ continue
+ key = (
+ path.get('target_type'),
+ path.get('server_name') or '',
+ path.get('database_name') or '',
+ path.get('sccm_impact_site') or '',
+ )
+ role_names_by_scope.setdefault(key, set()).add(
+ self._role_key(path.get('target_name') or '')
+ )
+
+ filtered = []
+ for path in paths:
+ target_type = path.get('target_type')
+ target_name = self._role_key(path.get('target_name') or '')
+ key = (
+ target_type,
+ path.get('server_name') or '',
+ path.get('database_name') or '',
+ path.get('sccm_impact_site') or '',
+ )
+ scope_roles = role_names_by_scope.get(key, set())
+ if target_type == 'MSSQL_ServerRole' and 'SYSADMIN' in scope_roles:
+ if target_name in _SYSADMIN_SUBORDINATES:
+ continue
+ if target_type == 'MSSQL_DatabaseRole' and 'DB_OWNER' in scope_roles:
+ if target_name in _DB_OWNER_SUBORDINATES:
+ continue
+ filtered.append(path)
+ return filtered
+
+ def _role_key(self, name):
+ return (name or '').split('@')[0].upper()
+
+ def _format_path_line(self, path):
+ attack_type = path.get('attack_type', '')
+ server = (path.get('server_name') or '').split(':')[0]
+ server_display = server.lower() if server else 'unknown'
+
+ if attack_type == 'privilege_escalation':
+ chain = self._build_chain(path)
+ return self._append_sccm_scope(chain, path)
+
+ if attack_type == 'coercion':
+ target_login = self._clean_name(path.get('target_name', '') or 'unknown login')
+ principal_name = self._clean_name(path.get('principal_name', '') or 'principal')
+ edge = (path.get('path_edges') or ['CoerceAndRelayToMSSQL'])[0]
+ return self._append_sccm_scope(
+ f"{principal_name} -> {edge} -> {target_login}",
+ path,
+ )
+
+ if attack_type == 'service_account':
+ principal_name = self._clean_name(path.get('principal_name', '') or 'principal')
+ return self._append_sccm_scope(
+ f"{principal_name} -> MSSQL_ServiceAccountFor -> {server_display}",
+ path,
+ )
+
+ return ""
+
+ def _build_chain(self, path):
+ edges = path.get('path_edges', [])
+ edge_contexts = path.get('path_edge_contexts', [])
+ nodes = path.get('path_node_names', [])
+
+ if not edges or not nodes:
+ return ""
+
+ parts = [self._clean_name(nodes[0])]
+ for i, edge in enumerate(edges):
+ next_name = self._clean_name(nodes[i + 1]) if i + 1 < len(nodes) else "?"
+ context = edge_contexts[i] if i < len(edge_contexts) else None
+ parts.append(self._format_edge(edge, context))
+ parts.append(next_name)
+
+ return " -> ".join(parts)
+
+ def _format_edge(self, edge, context=None):
+ if context:
+ return f"{edge} ({context})"
+ return edge
+
+ def _append_sccm_scope(self, chain, path):
+ scope = self._sccm_scope_chain(path)
+ if chain and scope:
+ return f"{chain} | {scope}"
+ return chain or scope
+
+ def _sccm_scope_chain(self, path):
+ target_type = path.get('target_type', '')
+ if target_type in ('SCCM_Site', 'SCCM_AdminUser'):
+ return ''
+
+ site = path.get('sccm_impact_site') or ''
+ if not site:
+ return ''
+
+ db = path.get('sccm_impact_db') or ''
+ if db:
+ return f"MSSQL_Database({db}) -> SCCM_AssignAllPermissions -> SCCM_Site({site})"
+ return f"SCCM_AssignAllPermissions -> SCCM_Site({site})"
+
+ def _path_node_names_expr(self, nodes_expr):
+ return f"""[node IN {nodes_expr} | CASE
+ WHEN node:MSSQL_Database
+ THEN 'MSSQL_Database(' + coalesce(node.name, node.objectid, 'Unknown') + ')'
+ WHEN node:SCCM_Site
+ THEN 'SCCM_Site(' + coalesce(node.siteCode, node.displayName, node.objectid, 'Unknown') + ')'
+ ELSE coalesce(
+ node.name,
+ node.siteCode,
+ node.displayName,
+ {primary_label_expr('node')},
+ 'Unknown'
+ )
+ END]"""
+
+ def _normalize_host(self, name):
+ return (name or '').split(':')[0].lower()
+
+ def _clean_name(self, name):
+ if not name:
+ return "?"
+ if '\\' in name:
+ name = name.split('\\')[-1]
+ if '@' in name:
+ base, suffix = name.rsplit('@', 1)
+ if '.' in suffix:
+ name = base
+ if ':' in name:
+ name = name.split(':')[0]
+ return name
diff --git a/docs/demo.tape b/docs/demo.tape
index dd21735..1a41124 100644
--- a/docs/demo.tape
+++ b/docs/demo.tape
@@ -1,4 +1,4 @@
-Output docs/images/demo.mp4
+Output docs/images/demo-slideshow.mp4
Set FontSize 18
Set Width 900
diff --git a/docs/images/ad-report-slideshow.mp4 b/docs/images/ad-report-slideshow.mp4
new file mode 100644
index 0000000..e53927e
Binary files /dev/null and b/docs/images/ad-report-slideshow.mp4 differ
diff --git a/docs/images/ad-report-slideshow.webp b/docs/images/ad-report-slideshow.webp
new file mode 100644
index 0000000..0877448
Binary files /dev/null and b/docs/images/ad-report-slideshow.webp differ
diff --git a/docs/images/adpathfinder-blog-hero-v3.webp b/docs/images/adpathfinder-blog-hero-v3.webp
new file mode 100644
index 0000000..dd6088d
Binary files /dev/null and b/docs/images/adpathfinder-blog-hero-v3.webp differ
diff --git a/docs/images/adpathfinder-opengraph-chain.svg b/docs/images/adpathfinder-opengraph-chain.svg
new file mode 100644
index 0000000..2e4e7e1
--- /dev/null
+++ b/docs/images/adpathfinder-opengraph-chain.svg
@@ -0,0 +1,145 @@
+
+ ADPathFinder collector-spanning MSSQL to SCCM path
+ A diagram showing J.REPORTER from SharpHound joined to an MSSQL login, assuming ReportSvc, crossing a linked SQL server, executing on the SCCM SQL host as DA, and reaching SCCM Site TRN.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ One path, three collectors
+ A single escalation path assembled from AD identity, SQL privilege, host execution, and SCCM site-control edges.
+
+
+
+ Collector contributions
+
+
+ SharpHound
+ AD users, computers, SIDs
+
+
+ MSSQLHound
+ SQL login, impersonation, link and host-exec evidence
+
+
+ ConfigManBearPig
+ SCCM site permission edges
+
+
+
+
+
+
+ J.REPORTER
+ Domain User
+
+
+ j.reporter
+ MSSQL_Login
+
+
+ ReportSvc
+ Assumed login
+
+
+ lab-sql01
+ Source SQL
+
+
+ sccmdb
+ Linked SQL
+
+
+
+
+
+
+
+ MSSQL_HasLogin
+
+ MSSQL_ExecuteAs
+
+ MSSQL_Connect
+
+ MSSQL_LinkedAsAdmin
+
+
+
+
+
+
+ Runs as DA@TRAINING.LOCAL
+ sccmdb's SQL service account
+
+
+
+ MSSQL_ExecuteOnHost
+
+
+ SCCMDB.TRAINING.LOCAL
+ Computer from SharpHound
+ command execution target
+
+
+ SCCM_Site(TRN)
+ High-value SCCM target
+
+
+
+ SCCM_AssignAllPermissions
+ SCCM admin rights on the site
+
+
diff --git a/docs/images/client-report-thumb.png b/docs/images/client-report-thumb.png
deleted file mode 100644
index 77457f4..0000000
Binary files a/docs/images/client-report-thumb.png and /dev/null differ
diff --git a/docs/images/client-report.mp4 b/docs/images/client-report.mp4
deleted file mode 100644
index f8e5b8d..0000000
Binary files a/docs/images/client-report.mp4 and /dev/null differ
diff --git a/docs/images/client-report.webp b/docs/images/client-report.webp
deleted file mode 100644
index 02e2eb7..0000000
Binary files a/docs/images/client-report.webp and /dev/null differ
diff --git a/docs/images/demo-slideshow.mp4 b/docs/images/demo-slideshow.mp4
new file mode 100644
index 0000000..bacb43a
Binary files /dev/null and b/docs/images/demo-slideshow.mp4 differ
diff --git a/docs/images/demo-slideshow.webp b/docs/images/demo-slideshow.webp
new file mode 100644
index 0000000..486a307
Binary files /dev/null and b/docs/images/demo-slideshow.webp differ
diff --git a/docs/images/demo-thumb.png b/docs/images/demo-thumb.png
deleted file mode 100644
index 644dfa3..0000000
Binary files a/docs/images/demo-thumb.png and /dev/null differ
diff --git a/docs/images/demo.mp4 b/docs/images/demo.mp4
deleted file mode 100644
index e6167a8..0000000
Binary files a/docs/images/demo.mp4 and /dev/null differ
diff --git a/docs/images/demo.webp b/docs/images/demo.webp
deleted file mode 100644
index 83015c6..0000000
Binary files a/docs/images/demo.webp and /dev/null differ
diff --git a/docs/images/password-audit-slideshow.mp4 b/docs/images/password-audit-slideshow.mp4
new file mode 100644
index 0000000..9bc6020
Binary files /dev/null and b/docs/images/password-audit-slideshow.mp4 differ
diff --git a/docs/images/password-audit-slideshow.webp b/docs/images/password-audit-slideshow.webp
new file mode 100644
index 0000000..5eb6f69
Binary files /dev/null and b/docs/images/password-audit-slideshow.webp differ
diff --git a/docs/images/password-audit-thumb.png b/docs/images/password-audit-thumb.png
deleted file mode 100644
index 21173d1..0000000
Binary files a/docs/images/password-audit-thumb.png and /dev/null differ
diff --git a/docs/images/password-audit.mp4 b/docs/images/password-audit.mp4
deleted file mode 100644
index 8edceab..0000000
Binary files a/docs/images/password-audit.mp4 and /dev/null differ
diff --git a/docs/images/password-audit.webp b/docs/images/password-audit.webp
deleted file mode 100644
index 2b9a2fd..0000000
Binary files a/docs/images/password-audit.webp and /dev/null differ
diff --git a/modules/BloodhoundImporter.py b/modules/BloodhoundImporter.py
index e3eabce..e9d815f 100644
--- a/modules/BloodhoundImporter.py
+++ b/modules/BloodhoundImporter.py
@@ -21,6 +21,10 @@
reserved_labels,
safe_cypher_identifier,
)
+from .mssql_post_import import (
+ canonicalize_mssql_linked_server_edges,
+ dedupe_mssql_servers,
+)
_NEO4J_PROPERTY_SCALARS = (str, int, float, bool)
@@ -71,11 +75,13 @@ def __init__(self, neo4j_conn, bloodhound_username: Optional[str] = None,
bloodhound_password: Optional[str] = None,
base_url: Optional[str] = None,
ingestion_timeout_seconds: Optional[int] = None,
- request_timeout_seconds: Optional[int] = None):
+ request_timeout_seconds: Optional[int] = None,
+ diagnostics=None):
self.connection = neo4j_conn
self.user = bloodhound_username
self.pwd = bloodhound_password
self.base_url = (base_url or "http://localhost:8080").rstrip('/')
+ self._diagnostics = diagnostics
self.jwt = ""
self.ingestion_timeout_seconds = self._resolve_ingestion_timeout(
ingestion_timeout_seconds
@@ -139,7 +145,7 @@ def import_zip(self, zip_paths) -> bool:
parts.append(f"{seed_total} seed skipped")
print(f"[*] Importing {len(entries)} files ({', '.join(parts)})")
- self._upload_json_files(entries)
+ self._upload_json_files(entries, validate_raw=False)
print("[+] Import completed successfully")
return True
@@ -172,6 +178,13 @@ def _authenticate(self) -> None:
def _extract_zip(self, zip_path: Path, extract_dir: Path) -> List[Path]:
try:
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
+ extract_root = extract_dir.resolve()
+ for member in zip_ref.namelist():
+ dest = (extract_root / member).resolve()
+ if not dest.is_relative_to(extract_root):
+ raise Exception(
+ f"ZIP entry escapes extract directory: {member}"
+ )
zip_ref.extractall(extract_dir)
except zipfile.BadZipFile:
raise Exception("The provided file is not a valid ZIP archive.")
@@ -330,14 +343,13 @@ def _filter_companion_properties(
data['graph']['nodes'] = out_nodes
return data
- def _upload_json_files(self, entries: List[JsonEntry]) -> None:
+ def _upload_json_files(self, entries: List[JsonEntry], *, validate_raw: bool = True) -> None:
# SharpHound goes through CE so its native schema and post-processing
# remain authoritative. OpenGraph companion/custom collector files are
# loaded directly below; repeated CE analysis passes can OOM small
# local stacks and custom-kind uploads can stall with empty statuses.
- # Raw validation runs before AD companion filtering so malformed
- # property/metadata containers fail before upload or graph mutation.
- self._validate_opengraph_entries(entries)
+ if validate_raw:
+ self._validate_opengraph_entries(entries)
headers = {
"User-Agent": "bh-automation",
"Authorization": f"Bearer {self.jwt}",
@@ -381,6 +393,9 @@ def _upload_json_files(self, entries: List[JsonEntry]) -> None:
for label in sorted(stub_labels_used):
self._merge_orphan_stubs(label)
+ self._dedupe_mssql_servers()
+ self._canonicalize_mssql_linked_server_edges()
+
# Evaluate contracts after orphan-stub merge so future requirement
# shapes see the final post-import graph state rather than a temporary
# collector-stub layout.
@@ -534,16 +549,13 @@ def _validate_opengraph_payload(
f"{source_kind!r} is reserved for SharpHound AD nodes"
)
+ skipped_empty_id: List[int] = []
for index, node in enumerate(nodes):
if not isinstance(node, dict):
raise ValueError(
f"OpenGraph {source_name}: node {index} must be an object"
)
context_id = node.get('id', f"index {index}")
- if not node.get('id'):
- raise ValueError(
- f"OpenGraph {source_name}: node {index} missing required id"
- )
if 'kinds' not in node or node.get('kinds') == []:
labels = ['Base']
else:
@@ -564,6 +576,64 @@ def _validate_opengraph_payload(
'node label',
f"OpenGraph {source_name}: node {context_id!r} label",
)
+ if not node.get('id'):
+ skipped_empty_id.append(index)
+
+ if skipped_empty_id:
+ dns_to_ids: Dict[str, Set[str]] = {}
+ skipped_empty_id_set = set(skipped_empty_id)
+ for i, n in enumerate(nodes):
+ if i in skipped_empty_id_set:
+ continue
+ nid = n.get('id', '')
+ dns = (n.get('properties') or {}).get('DNSHostName', '')
+ if nid and dns:
+ dns_to_ids.setdefault(str(dns).lower(), set()).add(str(nid))
+
+ ambiguous_dns = {
+ dns for dns, node_ids in dns_to_ids.items()
+ if len(node_ids) > 1
+ }
+ dns_to_id = {
+ dns: next(iter(node_ids))
+ for dns, node_ids in dns_to_ids.items()
+ if dns not in ambiguous_dns
+ }
+
+ resolved = 0
+ ambiguous = 0
+ still_empty: List[int] = []
+ for i in skipped_empty_id:
+ dns = (nodes[i].get('properties') or {}).get('DNSHostName', '')
+ dns_key = str(dns).lower() if dns else ''
+ if dns_key in ambiguous_dns:
+ ambiguous += 1
+ still_empty.append(i)
+ continue
+ matched_id = dns_to_id.get(dns_key) if dns_key else None
+ if matched_id:
+ nodes[i]['id'] = matched_id
+ resolved += 1
+ else:
+ still_empty.append(i)
+
+ if resolved:
+ self._warn_import(
+ "opengraph_empty_id_resolved",
+ f"OpenGraph {source_name}: resolved {resolved} empty-id node(s) via DNSHostName"
+ )
+ if ambiguous:
+ self._warn_import(
+ "opengraph_empty_id_ambiguous_dns",
+ f"OpenGraph {source_name}: skipped {ambiguous} empty-id node(s) with ambiguous DNSHostName"
+ )
+ if still_empty:
+ skip_set = set(still_empty)
+ graph['nodes'] = [n for i, n in enumerate(nodes) if i not in skip_set]
+ self._warn_import(
+ "opengraph_empty_id_skipped",
+ f"OpenGraph {source_name}: skipped {len(still_empty)} node(s) with empty id"
+ )
for index, edge in enumerate(edges):
if not isinstance(edge, dict):
@@ -684,20 +754,15 @@ def _safe_opengraph_identifier(self, value: str, kind: str, context: str) -> str
except ValueError as exc:
raise ValueError(f"{context} has unsupported identifier {value!r}") from exc
+ def _warn_import(self, source: str, message: str) -> None:
+ print(f"[!] {message}")
+ if self._diagnostics:
+ self._diagnostics.record_warning(f"import:{source}", message)
+
def _stub_label_for_source_kind(self, data: dict) -> str:
- # Defensive guard for direct callers that bypass preflight validation.
- metadata = data.get('metadata', {})
- if not isinstance(metadata, dict):
- raise ValueError("OpenGraph metadata must be an object")
- source_kind = metadata.get('source_kind', '')
- if 'source_kind' in metadata and not isinstance(source_kind, str):
- raise ValueError("OpenGraph metadata.source_kind must be a string")
+ source_kind = data.get('metadata', {}).get('source_kind', '')
if source_kind and source_kind.strip():
label = safe_cypher_identifier(source_kind, 'source_kind')
- if label in reserved_labels():
- raise ValueError(
- f"Unsupported source_kind reserved for AD nodes: {source_kind!r}"
- )
return label
return 'OpenGraph_Stub'
@@ -993,6 +1058,12 @@ def _endpoint_match_clause(
f"Unsupported OpenGraph endpoint match strategy: {endpoint.match_by!r}"
)
+ def _dedupe_mssql_servers(self) -> int:
+ return dedupe_mssql_servers(self.connection)
+
+ def _canonicalize_mssql_linked_server_edges(self) -> int:
+ return canonicalize_mssql_linked_server_edges(self.connection)
+
def _merge_orphan_stubs(self, label: str) -> None:
# don't copy the collector label onto the AD node — account_analysis would mis-classify it as a stub
label = safe_cypher_identifier(label, 'orphan stub label')
@@ -1021,8 +1092,10 @@ def _merge_orphan_stubs(self, label: str) -> None:
WITH stub, old, target
MATCH (real) WHERE real.objectid = stub.objectid
AND (real:User OR real:Computer OR real:Group)
- CREATE (real)-[new:`{rtype}`]->(target)
- SET new = properties(old)
+ AND target <> real
+ MERGE (real)-[new:`{rtype}`]->(target)
+ WITH old, new, properties(old) AS old_props, properties(new) AS existing_props
+ SET new += old_props SET new += existing_props
DELETE old
""")
self.connection.query(f"""
@@ -1031,8 +1104,10 @@ def _merge_orphan_stubs(self, label: str) -> None:
WITH source, old, stub
MATCH (real) WHERE real.objectid = stub.objectid
AND (real:User OR real:Computer OR real:Group)
- CREATE (source)-[new:`{rtype}`]->(real)
- SET new = properties(old)
+ AND source <> real
+ MERGE (source)-[new:`{rtype}`]->(real)
+ WITH old, new, properties(old) AS old_props, properties(new) AS existing_props
+ SET new += old_props SET new += existing_props
DELETE old
""")
@@ -1163,45 +1238,34 @@ def _collector_identity(
_REQUEST_TIMEOUT_ENV = "ADPF_BH_REQUEST_TIMEOUT_SECONDS"
def _resolve_ingestion_timeout(self, timeout: Optional[int]) -> int:
- raw_timeout = (
- os.environ.get(self._INGESTION_TIMEOUT_ENV)
- if timeout is None
- else timeout
+ return self._resolve_int_timeout(
+ timeout,
+ self._INGESTION_TIMEOUT_ENV,
+ self._INGESTION_TIMEOUT_SECONDS,
)
- if raw_timeout is None:
- raw_timeout = self._INGESTION_TIMEOUT_SECONDS
- try:
- resolved = int(raw_timeout)
- except (TypeError, ValueError) as exc:
- raise ValueError(
- f"{self._INGESTION_TIMEOUT_ENV} must be a positive integer "
- "number of seconds"
- ) from exc
- if resolved <= 0:
- raise ValueError(
- f"{self._INGESTION_TIMEOUT_ENV} must be a positive integer "
- "number of seconds"
- )
- return resolved
def _resolve_request_timeout(self, timeout: Optional[int]) -> int:
- raw_timeout = (
- os.environ.get(self._REQUEST_TIMEOUT_ENV)
- if timeout is None
- else timeout
+ return self._resolve_int_timeout(
+ timeout,
+ self._REQUEST_TIMEOUT_ENV,
+ self._REQUEST_TIMEOUT_SECONDS,
)
+
+ @staticmethod
+ def _resolve_int_timeout(timeout: Optional[int], env_var: str, default: int) -> int:
+ raw_timeout = os.environ.get(env_var) if timeout is None else timeout
if raw_timeout is None:
- raw_timeout = self._REQUEST_TIMEOUT_SECONDS
+ raw_timeout = default
try:
resolved = int(raw_timeout)
except (TypeError, ValueError) as exc:
raise ValueError(
- f"{self._REQUEST_TIMEOUT_ENV} must be a positive integer "
+ f"{env_var} must be a positive integer "
"number of seconds"
) from exc
if resolved <= 0:
raise ValueError(
- f"{self._REQUEST_TIMEOUT_ENV} must be a positive integer "
+ f"{env_var} must be a positive integer "
"number of seconds"
)
return resolved
diff --git a/modules/diagnostics.py b/modules/diagnostics.py
index 42d4750..d1b89b7 100644
--- a/modules/diagnostics.py
+++ b/modules/diagnostics.py
@@ -28,6 +28,7 @@ def __init__(self):
self.cross_domain_checks = {}
self.mssql_sccm = {}
self.report_generation = {}
+ self.warnings = []
self.errors = []
self.args = {}
self.domains = []
@@ -84,6 +85,16 @@ def record_error(self, source, error, domain=None):
entry["domain"] = domain
self.errors.append(entry)
+ def record_warning(self, source, warning, domain=None):
+ entry = {
+ "source": source,
+ "warning": str(warning),
+ "timestamp": datetime.now().isoformat()
+ }
+ if domain is not None:
+ entry["domain"] = domain
+ self.warnings.append(entry)
+
def record_escalation_batch(self, domain, batch_num, size, duration_ms,
paths_found, success=True, error=None):
if domain not in self.escalation_batches:
@@ -173,6 +184,7 @@ def to_dict(self):
"mssql_sccm": self.mssql_sccm,
"check_ordering": self.check_order,
"report_generation": self.report_generation,
+ "warnings": self.warnings,
"errors": self.errors
}
diff --git a/modules/main.py b/modules/main.py
index d46277c..4c74418 100644
--- a/modules/main.py
+++ b/modules/main.py
@@ -71,7 +71,7 @@ def _record_final_inventory(report_dir, domain_name, diagnostics):
diagnostics.report_generation[domain_name] = report_files
-def populate_neo4j_passwords(conn, neo4j_data, account_analysis, ntds_file_path, config, bloodhound_username=None, bloodhound_password=None, *, bloodhound_url):
+def populate_neo4j_passwords(conn, neo4j_data, account_analysis, ntds_file_path, config, bloodhound_username=None, bloodhound_password=None, *, bloodhound_url, diagnostics=None):
# Skip if >85% of cracked accounts already have passwords in Neo4j
if not hasattr(account_analysis, 'cracked_accounts') or not account_analysis.cracked_accounts:
return False
@@ -104,7 +104,7 @@ def populate_neo4j_passwords(conn, neo4j_data, account_analysis, ntds_file_path,
if not bloodhound_enabled or not bloodhound_username or not bloodhound_password:
return False
- importer = BloodhoundImporter(conn, bloodhound_username=bloodhound_username, bloodhound_password=bloodhound_password, base_url=bloodhound_url)
+ importer = BloodhoundImporter(conn, bloodhound_username=bloodhound_username, bloodhound_password=bloodhound_password, base_url=bloodhound_url, diagnostics=diagnostics)
importer.neo4j_data = neo4j_data
importer.cracked_accounts = account_analysis.cracked_accounts
@@ -366,6 +366,17 @@ def main():
resolved = load_resolved_config(config_file)
+ diagnostics = None
+ if args.diagnostics:
+ from .diagnostics import DiagnosticsCollector, compute_audit_mode
+ diagnostics = DiagnosticsCollector()
+ diagnostics.args = {
+ "ad": args.ad, "pwd": args.pwd,
+ "ntds": args.ntds is not None, "potfile": args.potfile is not None,
+ "unsafe_report": args.unsafe_report,
+ "mode": compute_audit_mode(args.ad, args.pwd),
+ }
+
if args.pwd and not args.ntds:
print("Error: Password audit requires NTDS data. Please provide it using the --ntds parameter.")
sys.exit(1)
@@ -417,7 +428,7 @@ def main():
sys.exit(1)
bloodhound_enabled = True
- importer = BloodhoundImporter(conn, bloodhound_username=bloodhound_username, bloodhound_password=bloodhound_password, base_url=resolved.bh_url)
+ importer = BloodhoundImporter(conn, bloodhound_username=bloodhound_username, bloodhound_password=bloodhound_password, base_url=resolved.bh_url, diagnostics=diagnostics)
if args.delete:
standalone_delete = not (args.import_file or args.ad or args.pwd)
@@ -550,17 +561,6 @@ def main():
ntds_hashes = None
ntds_file_path = None
- diagnostics = None
- if args.diagnostics:
- from .diagnostics import DiagnosticsCollector, compute_audit_mode
- diagnostics = DiagnosticsCollector()
- diagnostics.args = {
- "ad": args.ad, "pwd": args.pwd,
- "ntds": args.ntds is not None, "potfile": args.potfile is not None,
- "unsafe_report": args.unsafe_report,
- "mode": compute_audit_mode(args.ad, args.pwd),
- }
-
neo4j_data = Neo4jData(conn, excluded_relationships=excluded_relationships, diagnostics=diagnostics)
domain_name = neo4j_data.get_domain_name()
if domain_name == "Unknown Domain":
@@ -612,7 +612,7 @@ def main():
cracked_users_list = list(accountanalysis.cracked_accounts.keys())
print(f"[*] Total number of cracked accounts to mark as owned: {len(cracked_users_list)}")
- importer = BloodhoundImporter(conn, bloodhound_username=bloodhound_username, bloodhound_password=bloodhound_password, base_url=resolved.bh_url)
+ importer = BloodhoundImporter(conn, bloodhound_username=bloodhound_username, bloodhound_password=bloodhound_password, base_url=resolved.bh_url, diagnostics=diagnostics)
importer.neo4j_data = neo4j_data
importer.cracked_accounts = accountanalysis.cracked_accounts
importer.mark_as_owned(cracked_users_list, ntds_file_path)
@@ -686,7 +686,7 @@ def main():
single_domain_reporting=reporting,
single_domain_start_message="Generating reports in directory: {report_dir}"
)
- if args.mark_owned and ntds_hashes and ntds_file_path: populate_neo4j_passwords(conn, neo4j_data, accountanalysis, ntds_file_path, config, bloodhound_username, bloodhound_password, bloodhound_url=resolved.bh_url)
+ if args.mark_owned and ntds_hashes and ntds_file_path: populate_neo4j_passwords(conn, neo4j_data, accountanalysis, ntds_file_path, config, bloodhound_username, bloodhound_password, bloodhound_url=resolved.bh_url, diagnostics=diagnostics)
elif main_choice == "2":
if ntds_hashes:
company_names = reporting.get_company_names()
@@ -696,7 +696,7 @@ def main():
single_domain_reporting=reporting,
single_domain_completion_message="Password audit reports have been generated in the {report_dir} directory."
)
- if args.mark_owned and ntds_hashes and ntds_file_path: populate_neo4j_passwords(conn, neo4j_data, accountanalysis, ntds_file_path, config, bloodhound_username, bloodhound_password, bloodhound_url=resolved.bh_url)
+ if args.mark_owned and ntds_hashes and ntds_file_path: populate_neo4j_passwords(conn, neo4j_data, accountanalysis, ntds_file_path, config, bloodhound_username, bloodhound_password, bloodhound_url=resolved.bh_url, diagnostics=diagnostics)
else:
print("Password Audit is not available as NTDS data was not provided.")
diff --git a/modules/mssql_post_import.py b/modules/mssql_post_import.py
new file mode 100644
index 0000000..a04f1de
--- /dev/null
+++ b/modules/mssql_post_import.py
@@ -0,0 +1,99 @@
+from .opengraph_identifiers import safe_cypher_identifier
+
+
+def dedupe_mssql_servers(connection) -> int:
+ groups = connection.query("""
+ MATCH (s:MSSQL_Server)
+ WHERE s.name IS NOT NULL
+ WITH s, toLower(s.name) AS canon
+ WITH canon, collect(s) AS servers
+ WHERE size(servers) > 1
+ UNWIND servers AS s
+ OPTIONAL MATCH (s)-[r]-()
+ WITH canon, s, count(r) AS deg, size(keys(s)) AS prop_count
+ ORDER BY canon, prop_count DESC, deg DESC
+ WITH canon, collect(s.objectid) AS ranked
+ RETURN head(ranked) AS canonical, tail(ranked) AS stale
+ """)
+
+ merged = 0
+ for row in groups or []:
+ canonical = row['canonical']
+ for stale in row['stale']:
+ connection.query(
+ "MATCH (s:MSSQL_Server {objectid: $stale}) "
+ "MATCH (c:MSSQL_Server {objectid: $canonical}) "
+ "WITH c, properties(s) AS sp, properties(c) AS cp "
+ "SET c += sp SET c += cp",
+ parameters={'stale': stale, 'canonical': canonical},
+ )
+ rel_types = connection.query(
+ "MATCH (s:MSSQL_Server {objectid: $stale})-[r]-() "
+ "RETURN DISTINCT type(r) AS rtype",
+ parameters={'stale': stale},
+ )
+ for rt in rel_types:
+ rtype = safe_cypher_identifier(rt['rtype'], 'relationship type')
+ connection.query(
+ f"MATCH (s:MSSQL_Server {{objectid: $stale}})-[old:`{rtype}`]->(t) "
+ f"MATCH (c:MSSQL_Server {{objectid: $canonical}}) "
+ "WHERE t <> c "
+ f"MERGE (c)-[new:`{rtype}`]->(t) "
+ "WITH old, new, properties(old) AS old_props, properties(new) AS existing_props "
+ "SET new += old_props SET new += existing_props "
+ "DELETE old",
+ parameters={'stale': stale, 'canonical': canonical},
+ )
+ connection.query(
+ f"MATCH (src)-[old:`{rtype}`]->(s:MSSQL_Server {{objectid: $stale}}) "
+ f"MATCH (c:MSSQL_Server {{objectid: $canonical}}) "
+ "WHERE src <> c "
+ f"MERGE (src)-[new:`{rtype}`]->(c) "
+ "WITH old, new, properties(old) AS old_props, properties(new) AS existing_props "
+ "SET new += old_props SET new += existing_props "
+ "DELETE old",
+ parameters={'stale': stale, 'canonical': canonical},
+ )
+ connection.query(
+ "MATCH (s:MSSQL_Server {objectid: $stale}) DETACH DELETE s",
+ parameters={'stale': stale},
+ )
+ merged += 1
+
+ if merged:
+ print(f"[*] Merged {merged} duplicate MSSQL_Server node(s)")
+ return merged
+
+
+def canonicalize_mssql_linked_server_edges(connection) -> int:
+ # Count canonical edges ensured by MERGE, not newly created edges.
+ merged = 0
+ for rel_type in ("MSSQL_LinkedTo", "MSSQL_LinkedAsAdmin"):
+ rtype = safe_cypher_identifier(rel_type, "relationship type")
+ rows = connection.query(f"""
+ MATCH (stub:MSSQL_Base)-[old:`{rtype}`]->(target:MSSQL_Server)
+ WHERE NOT stub:MSSQL_Server
+ AND stub.objectid IS NOT NULL
+ AND stub.objectid CONTAINS ':'
+ WITH old, target, split(stub.objectid, ':')[0] AS sourceSid
+ MATCH (source:MSSQL_Server)
+ WHERE source.objectid IS NOT NULL
+ AND source.objectid CONTAINS ':'
+ AND split(source.objectid, ':')[0] = sourceSid
+ AND source <> target
+ AND COUNT {{
+ MATCH (sidServer:MSSQL_Server)
+ WHERE sidServer.objectid CONTAINS ':'
+ AND split(sidServer.objectid, ':')[0] = sourceSid
+ }} = 1
+ MERGE (source)-[new:`{rtype}`]->(target)
+ WITH old, new, properties(old) AS old_props, properties(new) AS existing_props
+ SET new += old_props SET new += existing_props
+ RETURN count(DISTINCT new) AS merged
+ """)
+ if rows:
+ merged += rows[0].get("merged", 0) or 0
+
+ if merged:
+ print(f"[*] Merged {merged} canonical MSSQL linked-server edge(s)")
+ return merged
diff --git a/pyproject.toml b/pyproject.toml
index 4a42c6e..a46650a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -43,7 +43,7 @@ test = ["pytest>=7.0", "pytest-cov>=4.0", "mypy>=1.0"]
adpathfinder = "modules.main:main"
[tool.pytest.ini_options]
-addopts = "-m 'not integration'"
+addopts = "-m 'not integration and not neo4j'"
markers = [
"neo4j: requires a running Neo4j instance (set NEO4J_URI or use the docker one-liner)",
"integration: optional end-to-end smoke; not part of the default PR run",
diff --git a/sample_data/ConfigManBearPig.zip b/sample_data/ConfigManBearPig.zip
index 1e76cda..e54df3d 100644
Binary files a/sample_data/ConfigManBearPig.zip and b/sample_data/ConfigManBearPig.zip differ
diff --git a/sample_data/MSSQLHound.zip b/sample_data/MSSQLHound.zip
index bc3ad5c..cd9abc1 100644
Binary files a/sample_data/MSSQLHound.zip and b/sample_data/MSSQLHound.zip differ
diff --git a/sample_data/SharpHound.zip b/sample_data/SharpHound.zip
index f84e7db..cc67b69 100644
Binary files a/sample_data/SharpHound.zip and b/sample_data/SharpHound.zip differ
diff --git a/sample_reports/training.local_domain_audit.txt b/sample_reports/training.local_domain_audit.txt
index 4e83bf1..fb1f7de 100644
--- a/sample_reports/training.local_domain_audit.txt
+++ b/sample_reports/training.local_domain_audit.txt
@@ -1,50 +1,43 @@
Risk Profile Statistics:
-Critical: Total Users - 51, Total Computers - 10, Default Groups - 4, Total - 65
- Non-admin Users with Escalation Paths: 36
- Admin with Weak Password: 5
- Computer has Weak Password: 5
+Critical: Total Users - 50, Total Computers - 5, Default Groups - 4, Total - 59
+ Non-admin Users with Escalation Paths: 37
+ SCCM Privilege Escalation: 8
Default Groups with Privilege Escalation Paths: 4
ESC1 — Enrollee Supplies Subject: 2
- SCCM Database Compromise: 2
- AS-REP Roastable with Admin privileges and Weak Password: 1
ESC4 — Template ACL Abuse: 1
ESC6 — CA Allows User-Specified SAN: 1
ESC7 — CA ManageCA Permissions: 1
- Kerberoastable with Admin privileges and Weak Password: 1
SCCM Hierarchy Takeover via AdminService Relay (TAKEOVER-5): 1
SCCM Hierarchy Takeover via HTTP-to-LDAP/LDAPS Relay on Domain Controller (TAKEOVER-8): 1
SCCM Hierarchy Takeover via NTLM Relay (TAKEOVER-1): 1
SCCM Hierarchy Takeover via SMB Relay to SMS Provider (TAKEOVER-6): 1
SCCM Hierarchy Takeover via SMB Relay to Site Database (TAKEOVER-2): 1
- User with Weak Password and Unconstrained Delegation: 1
-High: Total Users - 11, Total Computers - 123, Default Groups - 0, Total - 134
+High: Total Users - 12, Total Computers - 124, Default Groups - 0, Total - 136
Computers with Escalation Paths: 115
- Non-admin AS-REP Roastable with Weak Password: 4
+ MSSQL Privilege Escalation: 7
Computers with SMB Signing Disabled and Escalation Paths: 2
Computers with WebClient and Escalation Paths: 2
ESC2 — Any Purpose EKU: 2
+ MSSQL Server Vulnerable to NTLM Relay: 2
Computer with Unconstrained Delegation: 1
ESC3 — Enrollment Agent: 1
ESC8-vulnerable Enterprise CAs: 1
ESC9 — No Security Extension: 1
- MSSQL Privilege Escalation: 1
- MSSQL Server Vulnerable to NTLM Relay: 1
- Non-admin Kerberoastable with Weak Password: 1
SCCM Infrastructure SMB Relay (ELEVATE-1): 1
User with Unconstrained Delegation: 1
-Medium: Total Users - 55, Total Computers - 5, Default Groups - 0, Total - 60
- Non-admin with Weak Password: 31
- Account with Shared Password: 21
+Medium: Total Users - 8, Total Computers - 6, Default Groups - 0, Total - 14
+ MSSQL Login: 7
Computers with SMB Signing Disabled: 3
- MSSQL Login: 2
Computer with Constrained Delegation: 1
KRBTGT Password Older Than 6 Months: 1
+ MSSQL Login Impersonation: 1
SCCM Management Point - Client Policy Retrieval Review: 1
-Low: Total Users - 0, Total Computers - 0, Default Groups - 0, Total - 0
+Low: Total Users - 1, Total Computers - 0, Default Groups - 0, Total - 1
+ Tier-0 Session on Non-Tier-0 Host: 1
Info: Total Users - 0, Total Computers - 0, Default Groups - 0, Total - 0
@@ -54,74 +47,97 @@ Critical Risk Profiles:
▶ Domain Users
├─ GenericAll on ESC4 (CertTemplate)
+
├─ GenericAll on JENKINS.TRAINING.LOCAL (Computer)
- │ └─ LEADS TO -> DC01.TRAINING.LOCAL (Computer) (via 4 steps)
- │ └─ JENKINS.TRAINING.LOCAL (Computer) -> MemberOf -> DOMAIN COMPUTERS@TRAINING.LOCAL (Group) -> MemberOf -> AUTHENTICATED USERS@TRAINING.LOCAL (Group) -> GenericWrite -> SQLSERVER10.TRAINING.LOCAL (Computer) -> GenericWrite -> DC01.TRAINING.LOCAL (Computer)
- └─ GenericAll on TRAINING-DC01-CA (EnterpriseCA)
+ │ └─ LEADS TO -> SCCMDB.TRAINING.LOCAL (Computer) (via 4 steps)
+ │ └─ JENKINS.TRAINING.LOCAL (Computer) -> MemberOf -> DOMAIN COMPUTERS@TRAINING.LOCAL (Group) -> MemberOf -> AUTHENTICATED USERS@TRAINING.LOCAL (Group) -> MemberOf -> EVERYONE@TRAINING.LOCAL (Group) -> WriteAccountRestrictions -> SCCMDB.TRAINING.LOCAL (Computer)
+
+ └─ GenericAll on TRAINING-DC01-CA (AIACA) (EnterpriseCA)
▶ Domain Computers
└─ WriteOwner on WINDOWS10.TRAINING.LOCAL (Computer)
- └─ LEADS TO -> DC01.TRAINING.LOCAL (Computer) (via 4 steps)
- └─ WINDOWS10.TRAINING.LOCAL (Computer) -> MemberOf -> DOMAIN COMPUTERS@TRAINING.LOCAL (Group) -> MemberOf -> AUTHENTICATED USERS@TRAINING.LOCAL (Group) -> GenericWrite -> SQLSERVER10.TRAINING.LOCAL (Computer) -> GenericWrite -> DC01.TRAINING.LOCAL (Computer)
+ └─ LEADS TO -> SCCMDB.TRAINING.LOCAL (Computer) (via 4 steps)
+ └─ WINDOWS10.TRAINING.LOCAL (Computer) -> MemberOf -> DOMAIN COMPUTERS@TRAINING.LOCAL (Group) -> MemberOf -> AUTHENTICATED USERS@TRAINING.LOCAL (Group) -> MemberOf -> EVERYONE@TRAINING.LOCAL (Group) -> WriteAccountRestrictions -> SCCMDB.TRAINING.LOCAL (Computer)
▶ Authenticated Users
├─ WriteOwner on DEFAULT DOMAIN POLICY (GPO)
- ├─ GenericWrite on SQLSERVER10.TRAINING.LOCAL (Computer)
- │ └─ LEADS TO -> DC01.TRAINING.LOCAL (Computer) (via 1 steps)
- │ └─ SQLSERVER10.TRAINING.LOCAL (Computer) -> GenericWrite -> DC01.TRAINING.LOCAL (Computer)
- ├─ WriteDacl on DEFAULT DOMAIN POLICY (GPO)
- ├─ GenericWrite on DEFAULT DOMAIN POLICY (GPO)
+
├─ GenericAll on ANALYSISUSERS (OU)
- ├─ GenericAll on BADSUCSS (OU)
+
├─ GenericAll on BADSUCC (OU)
+
+ ├─ GenericAll on BADSUCSS (OU)
+
+ ├─ GenericWrite on DEFAULT DOMAIN POLICY (GPO)
+
+ ├─ WriteDacl on DEFAULT DOMAIN POLICY (GPO)
+
+ ├─ GenericWrite on SQLSERVER10.TRAINING.LOCAL (Computer)
+ │ └─ LEADS TO -> DC01.TRAINING.LOCAL (Computer) (via 1 steps)
+ │ └─ SQLSERVER10.TRAINING.LOCAL (Computer) -> WriteDacl -> DC01.TRAINING.LOCAL (Computer)
+
├─ GenericAll on ESC4_DISABLED (CertTemplate)
+
└─ GenericAll on ESC4 (CertTemplate)
▶ Everyone
+ ├─ CanRDP on DC01.TRAINING.LOCAL (Computer)
+ │ └─ LEADS TO -> ENTERPRISE DOMAIN CONTROLLERS@TRAINING.LOCAL (Group) (via 1 steps)
+ │ └─ DC01.TRAINING.LOCAL (Computer) -> MemberOf -> ENTERPRISE DOMAIN CONTROLLERS@TRAINING.LOCAL (Group)
+
└─ WriteAccountRestrictions on SCCMDB.TRAINING.LOCAL (Computer)
└─ LEADS TO -> DC01.TRAINING.LOCAL (Computer) (via 4 steps)
- └─ SCCMDB.TRAINING.LOCAL (Computer) -> MemberOf -> DOMAIN COMPUTERS@TRAINING.LOCAL (Group) -> MemberOf -> AUTHENTICATED USERS@TRAINING.LOCAL (Group) -> GenericWrite -> SQLSERVER10.TRAINING.LOCAL (Computer) -> GenericWrite -> DC01.TRAINING.LOCAL (Computer)
+ └─ SCCMDB.TRAINING.LOCAL (Computer) -> MemberOf -> DOMAIN COMPUTERS@TRAINING.LOCAL (Group) -> MemberOf -> AUTHENTICATED USERS@TRAINING.LOCAL (Group) -> MemberOf -> EVERYONE@TRAINING.LOCAL (Group) -> CanRDP -> DC01.TRAINING.LOCAL (Computer)
+
+ Non-admin Users with Escalation Paths: 37
+ Users with Shared Path (25): ADCS, AERIELL.SANDY, ANDREI.ISAHELLA, ANNADIANE.FAE, BOARDROOM, BUFFY.RUTHANNE, CHRISTA.JACQUENETTA, CHRISTAL.EUGENIA, CISCO, COMPUTER01, DCSYNC, DEMETRA.MYRTIA, ELSE.NATALA, J.REPORTER, JANNELLE.LAIRD, JOE.SMITH, JOJO.KATRINA, LEIGH.PENNIE, LOTTI.RENAE, PERLE.SELIE, ROBINA.KIPPAR, SABINE.MYRTIE, TEST, TEST.USERS, VICTORIA.REP
- Non-admin Users with Escalation Paths: 36
- Users with Shared Path (30): ADCS, AERIELL.SANDY, ANDREI.ISAHELLA, ANNADIANE.FAE, ANNETTE.LEON, BOARDROOM, BUFFY.RUTHANNE, CAL.EM, CHRISTA.JACQUENETTA, CHRISTAL.EUGENIA, CISCO, COMPUTER01, DCSYNC, DEBBIE.KISSIE, DEMETRA.MYRTIA, DOMAINNAMEASUSER, ELSE.NATALA, JANNELLE.LAIRD, JOE.SMITH, JOJO.KATRINA, LEIGH.PENNIE, LOTTI.RENAE, MARNIA.CHRISSIE, PERLE.SELIE, ROBINA.KIPPAR, SABINE.MYRTIE, TEST, TEST.USERS, UNCONSTRAINED, VICTORIA.REP
- Common Escalation Path: MemberOf -> DOMAIN USERS (Group) -> MemberOf -> AUTHENTICATED USERS (Group) -> CoerceAndRelayToSMB -> SCCMDB (Computer)
+ Common Escalation Path: MemberOf -> DOMAIN USERS (Group) -> MemberOf -> AUTHENTICATED USERS (Group) -> MemberOf -> EVERYONE (Group) -> WriteAccountRestrictions -> SCCMDB (Computer)
Users with Shared Path (2): CHERILYNN.ROSELIA, MAURINE.CAMMIE
+
Common Escalation Path: MemberOf -> FA-MIGORDOBE-DISTLIST1 (Group) -> GenericAll -> IVORY_MCCULLOUGH (User)
+ Users with Shared Path (2): DEBBIE.KISSIE, DOMAINNAMEASUSER
+
+ Common Escalation Path: MemberOf -> JO-NOWOMANNO-DISTLIST1 (Group) -> GenericAll -> AZRWSECS1000000 (Computer) -> GenericAll -> LAWANDA_FRANKS (User)
+
Users with Shared Path (1): ANICA.PAULINA
- Common Escalation Path: MemberOf -> TA-HOT-ADMINGROUP1 (Group) -> MemberOf -> SE-JAG-DISTLIST1 (Group) -> GenericAll -> DC01 (Computer)
+
+ Common Escalation Path: MemberOf -> TA-HOT-ADMINGROUP1 (Group) -> MemberOf -> SE-JAG-DISTLIST1 (Group) -> GenericAll -> SCCMDB (Computer)
+
+ Users with Shared Path (1): ANNETTE.LEON
+
+ Common Escalation Path: MemberOf -> BE-FER-DISTLIST1 (Group) -> GenericAll -> RU-187960050-ADMINGROUP1 (Group) -> GenericAll -> PA-860-DISTLIST1 (Group) -> GenericAll -> SCCMDB (Computer)
+
+ Users with Shared Path (1): CAL.EM
+
+ Common Escalation Path: MemberOf -> AN-COR-DISTLIST1 (Group) -> MemberOf -> CA-ZID-ADMINGROUP1 (Group) -> GenericAll -> FABIAN_WHITLEY (User)
Users with Shared Path (1): JOE.PATHFINDER
+
Common Escalation Path: GenericAll -> DC01 (Computer)
+ Users with Shared Path (1): MARNIA.CHRISSIE
+
+ Common Escalation Path: MemberOf -> LE-SAN-ADMINGROUP1 (Group) -> GenericAll -> TSTWAPPS1000001 (Computer) -> GenericAll -> JANELL_ALFORD (User)
+
Users with Shared Path (1): SQLSVC
- Common Escalation Path: GenericAll -> SCCMDB (Computer)
- Users with Shared Path (1): WELCOMEUSER1
- Common Escalation Path: GenericWrite -> DC01 (Computer)
+ Common Escalation Path: GenericAll -> SCCMDB (Computer)
+ Users with Shared Path (1): UNCONSTRAINED
- AS-REP Roastable with Admin privileges and Weak Password: 1
- ▶ JB
+ Common Escalation Path: MemberOf -> RO-SIVOYCONL-ADMINGROUP1 (Group) -> GenericAll -> CL-PIRATAS12-DISTLIST1 (Group) -> GenericAll -> JANELL_ALFORD (User)
- Admin with Weak Password: 5
- ▶ ADMINISTRATOR
- ▶ DA
- ▶ JB
- ▶ KORNEY.DARELLE
- ▶ TESTDA
+ Users with Shared Path (1): WELCOMEUSER1
- Computer has Weak Password: 5
- ▶ COMPUTER01.TRAINING.LOCAL
- ▶ LAB-SQL01.TRAINING.LOCAL
- ▶ SCCM.TRAINING.LOCAL
- ▶ SQLSERVER10.TRAINING.LOCAL
- ▶ WINDOWS10.TRAINING.LOCAL
+ Common Escalation Path: GenericWrite -> DC01 (Computer)
ESC1 — Enrollee Supplies Subject: 2
▶ ESC1 (TRAINING-DC01-CA ON DC01.TRAINING.LOCAL)
▶ AUTHENTICATED USERS, DOMAIN USERS
+
▶ ESC2 (TRAINING-DC01-CA ON DC01.TRAINING.LOCAL)
▶ AUTHENTICATED USERS, DOMAIN USERS
@@ -137,23 +153,13 @@ Critical Risk Profiles:
▶ TRAINING-DC01-CA (DC01.TRAINING.LOCAL)
▶ DOMAIN USERS
- Kerberoastable with Admin privileges and Weak Password: 1
- ▶ DA
-
- SCCM Database Compromise: 2
- ▶ LAB-SQL01.TRAINING.LOCAL
- ▶ LAB-SQL01$ -> MSSQL_IsMappedTo -> LAB-SQL01$ -> MSSQL_MemberOf -> DB_OWNER -> MSSQL_ControlDB -> CM_TRN -> can enable xp_cmdshell on sccmdb.training.local
- ▶ LAB-SQL01$ -> MSSQL_IsMappedTo -> LAB-SQL01$ -> MSSQL_MemberOf -> DB_OWNER -> MSSQL_ControlDB -> CM_TRN -> SCCM_AssignAllPermissions -> SCCM_Site -> RCE on all managed clients
- ▶ AUTHENTICATED USERS@TRAINING.LOCAL
- ▶ AUTHENTICATED USERS@TRAINING.LOCAL -> CoerceAndRelayToMSSQL -> LAB-SQL01$ -> can enable xp_cmdshell on sccmdb.training.local
-
SCCM Hierarchy Takeover via AdminService Relay (TAKEOVER-5): 1
▶ SCCMDB.TRAINING.LOCAL
▶ Coerce LAB-SQL01.TRAINING.LOCAL -> AdminService relay to https://SCCMDB.TRAINING.LOCAL/AdminService (Site TRN)
SCCM Hierarchy Takeover via HTTP-to-LDAP/LDAPS Relay on Domain Controller (TAKEOVER-8): 1
▶ DC01.TRAINING.LOCAL
- ▶ Coerce lab-sql01.training.local (Site Server) -> HTTP-to-LDAP/LDAPS NTLM relay to dc01.training.local
+ ▶ Coerce sccmdb.training.local (SMS Provider), lab-sql01.training.local (Site Server) -> HTTP-to-LDAP/LDAPS NTLM relay to dc01.training.local
SCCM Hierarchy Takeover via NTLM Relay (TAKEOVER-1): 1
▶ SCCMDB.TRAINING.LOCAL
@@ -167,97 +173,150 @@ Critical Risk Profiles:
▶ SCCMDB.TRAINING.LOCAL
▶ Coerce lab-sql01.training.local -> SMB relay to sccmdb.training.local (Site TRN)
- User with Weak Password and Unconstrained Delegation: 1
+ SCCM Privilege Escalation: 8
+ ▶ DOMAIN USERS@TRAINING.LOCAL
+ DOMAIN USERS > MSSQL_HasLogin > Domain Users > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > sysadmin > MSSQL_Database(CM_TRN) > SCCM_Site(TRN)
+ DOMAIN USERS > MSSQL_HasLogin > Domain Users > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > MSSQL_Database(CM_TRN) > MSSQL_Contains > db_owner@CM_TRN > SCCM_Site(TRN)
+
+ ▶ SQLSVC
+ sqlsvc > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > sysadmin > MSSQL_Database(CM_TRN) > SCCM_Site(TRN)
+ sqlsvc > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > MSSQL_Database(CM_TRN) > MSSQL_Contains > db_owner@CM_TRN > SCCM_Site(TRN)
+
+ ▶ JOE.PATHFINDER
+ joe.pathfinder > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > sysadmin > MSSQL_Database(CM_TRN) > SCCM_Site(TRN)
+ joe.pathfinder > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > MSSQL_Database(CM_TRN) > MSSQL_Contains > db_owner@CM_TRN > SCCM_Site(TRN)
+
+ ▶ WELCOMEUSER1
+ welcomeuser1 > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > sysadmin > MSSQL_Database(CM_TRN) > SCCM_Site(TRN)
+ welcomeuser1 > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > MSSQL_Database(CM_TRN) > MSSQL_Contains > db_owner@CM_TRN > SCCM_Site(TRN)
+
▶ UNCONSTRAINED
+ unconstrained > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > sysadmin > MSSQL_Database(CM_TRN) > SCCM_Site(TRN)
+ unconstrained > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > MSSQL_Database(CM_TRN) > MSSQL_Contains > db_owner@CM_TRN > SCCM_Site(TRN)
+
+ ▶ DCSYNC
+ dcsync > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > sysadmin > MSSQL_Database(CM_TRN) > SCCM_Site(TRN)
+ dcsync > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > MSSQL_Database(CM_TRN) > MSSQL_Contains > db_owner@CM_TRN > SCCM_Site(TRN)
+
+ ▶ J.REPORTER
+ j.reporter > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > sysadmin > MSSQL_Database(CM_TRN) > SCCM_Site(TRN)
+ j.reporter > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > MSSQL_Database(CM_TRN) > MSSQL_Contains > db_owner@CM_TRN > SCCM_Site(TRN)
+
+ ▶ AUTHENTICATED USERS@TRAINING.LOCAL
+ AUTHENTICATED USERS@TRAINING.LOCAL > MSSQL_CoerceAndRelayToMSSQL > LAB-SQL01$ > MSSQL_Database(CM_TRN) > SCCM_Site(TRN)
High Risk Profiles:
Computers with Escalation Paths: 115
- Computers with Shared Path (89): ADTEST_SOURCEC.TRAINING.LOCAL, ADTEST_TARGETC.TRAINING.LOCAL, ATTACKER-MACHINE.TRAINING.LOCAL, AWSWDBAS1000000.TRAINING.LOCAL, AWSWSECS1000001.TRAINING.LOCAL, AWSWSECS1000002.TRAINING.LOCAL, AWSWVIR1000000.TRAINING.LOCAL, AWSWVIR1000001.TRAINING.LOCAL, AWSWWKS1000000.TRAINING.LOCAL, AZRWDBAS1000000.TRAINING.LOCAL, AZRWLPT1000000.TRAINING.LOCAL, AZRWVIR1000000.TRAINING.LOCAL, AZRWWKS1000000.TRAINING.LOCAL, BDEWCTRX1000000.TRAINING.LOCAL, BDEWLPT1000000.TRAINING.LOCAL, BDEWSECS1000001.TRAINING.LOCAL, BDEWVIR1000000.TRAINING.LOCAL, BDEWVIR1000002.TRAINING.LOCAL, COMPUTER01.TRAINING.LOCAL, ESMWLPT1000000.TRAINING.LOCAL, ESMWLPT1000001.TRAINING.LOCAL, ESMWLPT1000002.TRAINING.LOCAL, ESMWVIR1000000.TRAINING.LOCAL, ESMWVIR1000001.TRAINING.LOCAL, ESMWWEBS1000000.TRAINING.LOCAL, ESMWWKS1000000.TRAINING.LOCAL, ESMWWKS1000001.TRAINING.LOCAL, FINWAPPS1000000.TRAINING.LOCAL, FINWAPPS1000001.TRAINING.LOCAL, FINWAPPS1000002.TRAINING.LOCAL, FINWCTRX1000000.TRAINING.LOCAL, FINWDBAS1000000.TRAINING.LOCAL, FINWLPT1000000.TRAINING.LOCAL, FINWLPT1000001.TRAINING.LOCAL, FINWLPT1000002.TRAINING.LOCAL, FINWVIR1000000.TRAINING.LOCAL, FINWVIR1000001.TRAINING.LOCAL, FINWWKS1000000.TRAINING.LOCAL, FINWWKS1000001.TRAINING.LOCAL, FSRWLPT1000000.TRAINING.LOCAL, FSRWSECS1000000.TRAINING.LOCAL, FSRWVIR1000000.TRAINING.LOCAL, FSRWVIR1000001.TRAINING.LOCAL, GOOWAPPS1000001.TRAINING.LOCAL, GOOWCTRX1000000.TRAINING.LOCAL, GOOWCTRX1000001.TRAINING.LOCAL, GOOWSECS1000000.TRAINING.LOCAL, GOOWWKS1000000.TRAINING.LOCAL, GOOWWKS1000001.TRAINING.LOCAL, HREWAPPS1000000.TRAINING.LOCAL, HREWLPT1000000.TRAINING.LOCAL, HREWLPT1000001.TRAINING.LOCAL, HREWLPT1000002.TRAINING.LOCAL, HREWSECS1000000.TRAINING.LOCAL, ITSWAPPS1000000.TRAINING.LOCAL, ITSWLPT1000000.TRAINING.LOCAL, ITSWLPT1000001.TRAINING.LOCAL, ITSWSECS1000000.TRAINING.LOCAL, ITSWVIR1000000.TRAINING.LOCAL, ITSWVIR1000001.TRAINING.LOCAL, ITSWWEBS1000000.TRAINING.LOCAL, JENKINS.TRAINING.LOCAL, NETSPI-TEST.TRAINING.LOCAL, OGCWAPPS1000000.TRAINING.LOCAL, OGCWAPPS1000001.TRAINING.LOCAL, OGCWDBAS1000000.TRAINING.LOCAL, OGCWLPT1000001.TRAINING.LOCAL, OGCWSECS1000000.TRAINING.LOCAL, OGCWVIR1000000.TRAINING.LOCAL, PRE2K.TRAINING.LOCAL, PRE2K1.TRAINING.LOCAL, SCCM.TRAINING.LOCAL, SECWAPPS1000001.TRAINING.LOCAL, SECWAPPS1000002.TRAINING.LOCAL, SECWLPT1000000.TRAINING.LOCAL, SECWWEBS1000000.TRAINING.LOCAL, SECWWEBS1000002.TRAINING.LOCAL, SECWWKS1000001.TRAINING.LOCAL, SECWWKS1000002.TRAINING.LOCAL, TSTWAPPS1000000.TRAINING.LOCAL, TSTWAPPS1000002.TRAINING.LOCAL, TSTWDBAS1000000.TRAINING.LOCAL, TSTWDBAS1000001.TRAINING.LOCAL, TSTWLPT1000001.TRAINING.LOCAL, TSTWVIR1000000.TRAINING.LOCAL, TSTWVIR1000001.TRAINING.LOCAL, TSTWWEBS1000000.TRAINING.LOCAL, TSTWWKS1000000.TRAINING.LOCAL, WINDOWS10.TRAINING.LOCAL
- Common Escalation Path: MemberOf -> DOMAIN COMPUTERS (Group) -> MemberOf -> AUTHENTICATED USERS (Group) -> CoerceAndRelayToSMB -> SCCMDB (Computer)
+ Computers with Shared Path (88): ADTEST_SOURCEC.TRAINING.LOCAL, ADTEST_TARGETC.TRAINING.LOCAL, ATTACKER-MACHINE.TRAINING.LOCAL, AWSWDBAS1000000.TRAINING.LOCAL, AWSWSECS1000001.TRAINING.LOCAL, AWSWSECS1000002.TRAINING.LOCAL, AWSWVIR1000000.TRAINING.LOCAL, AWSWVIR1000001.TRAINING.LOCAL, AWSWWKS1000000.TRAINING.LOCAL, AZRWDBAS1000000.TRAINING.LOCAL, AZRWLPT1000000.TRAINING.LOCAL, AZRWVIR1000000.TRAINING.LOCAL, AZRWWKS1000000.TRAINING.LOCAL, BDEWCTRX1000000.TRAINING.LOCAL, BDEWLPT1000000.TRAINING.LOCAL, BDEWSECS1000001.TRAINING.LOCAL, BDEWVIR1000000.TRAINING.LOCAL, BDEWVIR1000002.TRAINING.LOCAL, COMPUTER01.TRAINING.LOCAL, ESMWLPT1000000.TRAINING.LOCAL, ESMWLPT1000001.TRAINING.LOCAL, ESMWLPT1000002.TRAINING.LOCAL, ESMWVIR1000000.TRAINING.LOCAL, ESMWVIR1000001.TRAINING.LOCAL, ESMWWEBS1000000.TRAINING.LOCAL, ESMWWKS1000000.TRAINING.LOCAL, ESMWWKS1000001.TRAINING.LOCAL, FINWAPPS1000000.TRAINING.LOCAL, FINWAPPS1000001.TRAINING.LOCAL, FINWAPPS1000002.TRAINING.LOCAL, FINWCTRX1000000.TRAINING.LOCAL, FINWDBAS1000000.TRAINING.LOCAL, FINWLPT1000000.TRAINING.LOCAL, FINWLPT1000001.TRAINING.LOCAL, FINWLPT1000002.TRAINING.LOCAL, FINWVIR1000000.TRAINING.LOCAL, FINWVIR1000001.TRAINING.LOCAL, FINWWKS1000000.TRAINING.LOCAL, FINWWKS1000001.TRAINING.LOCAL, FSRWLPT1000000.TRAINING.LOCAL, FSRWSECS1000000.TRAINING.LOCAL, FSRWVIR1000000.TRAINING.LOCAL, FSRWVIR1000001.TRAINING.LOCAL, GOOWAPPS1000001.TRAINING.LOCAL, GOOWCTRX1000000.TRAINING.LOCAL, GOOWCTRX1000001.TRAINING.LOCAL, GOOWSECS1000000.TRAINING.LOCAL, GOOWWKS1000000.TRAINING.LOCAL, GOOWWKS1000001.TRAINING.LOCAL, HREWAPPS1000000.TRAINING.LOCAL, HREWLPT1000000.TRAINING.LOCAL, HREWLPT1000001.TRAINING.LOCAL, HREWLPT1000002.TRAINING.LOCAL, HREWSECS1000000.TRAINING.LOCAL, ITSWAPPS1000000.TRAINING.LOCAL, ITSWLPT1000000.TRAINING.LOCAL, ITSWLPT1000001.TRAINING.LOCAL, ITSWSECS1000000.TRAINING.LOCAL, ITSWVIR1000000.TRAINING.LOCAL, ITSWVIR1000001.TRAINING.LOCAL, ITSWWEBS1000000.TRAINING.LOCAL, JENKINS.TRAINING.LOCAL, LAB-SQL01.TRAINING.LOCAL, NETSPI-TEST.TRAINING.LOCAL, OGCWAPPS1000000.TRAINING.LOCAL, OGCWAPPS1000001.TRAINING.LOCAL, OGCWDBAS1000000.TRAINING.LOCAL, OGCWLPT1000001.TRAINING.LOCAL, OGCWSECS1000000.TRAINING.LOCAL, OGCWVIR1000000.TRAINING.LOCAL, PRE2K.TRAINING.LOCAL, PRE2K1.TRAINING.LOCAL, SCCM.TRAINING.LOCAL, SECWAPPS1000001.TRAINING.LOCAL, SECWAPPS1000002.TRAINING.LOCAL, SECWLPT1000000.TRAINING.LOCAL, SECWWEBS1000000.TRAINING.LOCAL, SECWWKS1000001.TRAINING.LOCAL, SECWWKS1000002.TRAINING.LOCAL, TSTWAPPS1000000.TRAINING.LOCAL, TSTWAPPS1000002.TRAINING.LOCAL, TSTWDBAS1000001.TRAINING.LOCAL, TSTWLPT1000001.TRAINING.LOCAL, TSTWVIR1000000.TRAINING.LOCAL, TSTWVIR1000001.TRAINING.LOCAL, TSTWWEBS1000000.TRAINING.LOCAL, TSTWWKS1000000.TRAINING.LOCAL, WINDOWS10.TRAINING.LOCAL
+
+ Common Escalation Path: MemberOf -> DOMAIN COMPUTERS (Group) -> MemberOf -> AUTHENTICATED USERS (Group) -> MemberOf -> EVERYONE (Group) -> WriteAccountRestrictions -> SCCMDB (Computer)
- Computers with Shared Path (3): AWSWWKS1000001.TRAINING.LOCAL, BDEWWKS1000000.TRAINING.LOCAL, GOOWAPPS1000000.TRAINING.LOCAL
- Common Escalation Path: GenericAll -> LAMBERT.LOTTE (User)
+ Computers with Shared Path (4): AWSWWKS1000001.TRAINING.LOCAL, BDEWWKS1000000.TRAINING.LOCAL, GOOWAPPS1000000.TRAINING.LOCAL, TSTWAPPS1000001.TRAINING.LOCAL
+
+ Common Escalation Path: GenericAll -> JANELL_ALFORD (User)
Computers with Shared Path (2): ACPLBGLD.TRAINING.LOCAL, IZFVTJER.TRAINING.LOCAL
+
Common Escalation Path: AllowedToAct -> SCCMDB (Computer)
Computers with Shared Path (2): AWSWSECS1000000.TRAINING.LOCAL, BDEWWKS1000001.TRAINING.LOCAL
- Common Escalation Path: GenericAll -> ADRIENNE_BRITT (User) -> GenericAll -> DC01 (Computer)
+
+ Common Escalation Path: GenericAll -> ADRIENNE_BRITT (User) -> GenericAll -> SCCMDB (Computer)
Computers with Shared Path (1): AZRWSECS1000000.TRAINING.LOCAL
+
Common Escalation Path: GenericAll -> LAWANDA_FRANKS (User)
Computers with Shared Path (1): BDEWSECS1000000.TRAINING.LOCAL
+
Common Escalation Path: MemberOf -> GR-9.71275E1-DISTLIST1 (Group) -> GenericAll -> REMOTE MANAGEMENT USERS (Group) -> MemberOf -> ADMINISTRATORS (Group)
Computers with Shared Path (1): BDEWVIR1000001.TRAINING.LOCAL
+
Common Escalation Path: GenericAll -> RICH_CANNON (User) -> GenericAll -> VERNA_BRANCH (User)
Computers with Shared Path (1): HREWWEBS1000000.TRAINING.LOCAL
+
Common Escalation Path: GenericAll -> CAROLE_ALFORD (User)
Computers with Shared Path (1): HREWWEBS1000001.TRAINING.LOCAL
- Common Escalation Path: GenericAll -> DC01 (Computer)
+
+ Common Escalation Path: GenericAll -> SCCMDB (Computer)
Computers with Shared Path (1): HREWWKS1000000.TRAINING.LOCAL
+
Common Escalation Path: MemberOf -> NE-DAN-DISTLIST1 (Group) -> GenericAll -> LOTTIE_BLANCHARD (User)
Computers with Shared Path (1): ITSWLPT1000002.TRAINING.LOCAL
+
Common Escalation Path: GenericAll -> FREIDA_GROSS (User)
Computers with Shared Path (1): KIOSK01.TRAINING.LOCAL
- Common Escalation Path: GenericAll -> BRADFORD_BUCKNER (User)
- Computers with Shared Path (1): LAB-SQL01.TRAINING.LOCAL
- Common Escalation Path: LocalAdminRequired -> SCCMDB (Computer)
+ Common Escalation Path: GenericAll -> BRADFORD_BUCKNER (User)
Computers with Shared Path (1): OGCWLPT1000000.TRAINING.LOCAL
- Common Escalation Path: GenericAll -> PASQUALE_SIMS (User) -> MemberOf -> SE-JAG-DISTLIST1 (Group) -> GenericAll -> DC01 (Computer)
+
+ Common Escalation Path: GenericAll -> PASQUALE_SIMS (User) -> MemberOf -> SE-JAG-DISTLIST1 (Group) -> GenericAll -> SCCMDB (Computer)
Computers with Shared Path (1): OGCWWKS1000000.TRAINING.LOCAL
- Common Escalation Path: MemberOf -> LY-BETITASBO-ADMINGROUP1 (Group) -> GenericAll -> CRISTINA_BERGER (User)
+
+ Common Escalation Path: MemberOf -> LY-BETITASBO-ADMINGROUP1 (Group) -> GenericAll -> SHELBY_ROBBINS (User)
Computers with Shared Path (1): SECRET$@TRAINING.LOCAL
- Common Escalation Path: MemberOf -> DOMAIN USERS (Group) -> MemberOf -> AUTHENTICATED USERS (Group) -> CoerceAndRelayToSMB -> SCCMDB (Computer)
+
+ Common Escalation Path: MemberOf -> DOMAIN USERS (Group) -> MemberOf -> AUTHENTICATED USERS (Group) -> MemberOf -> EVERYONE (Group) -> WriteAccountRestrictions -> SCCMDB (Computer)
Computers with Shared Path (1): SECWAPPS1000000.TRAINING.LOCAL
- Common Escalation Path: GenericAll -> CL-PIRATAS12-DISTLIST1 (Group) -> GenericAll -> LAMBERT.LOTTE (User)
+
+ Common Escalation Path: GenericAll -> CL-PIRATAS12-DISTLIST1 (Group) -> GenericAll -> JANELL_ALFORD (User)
Computers with Shared Path (1): SECWLPT1000001.TRAINING.LOCAL
- Common Escalation Path: GenericAll -> FOREST_SNOW (User) -> MemberOf -> 94-SUK-DISTLIST1 (Group) -> GenericAll -> DC01 (Computer)
+
+ Common Escalation Path: GenericAll -> FOREST_SNOW (User) -> MemberOf -> 94-SUK-DISTLIST1 (Group) -> GenericAll -> SCCMDB (Computer)
Computers with Shared Path (1): SECWWEBS1000001.TRAINING.LOCAL
+
Common Escalation Path: GenericAll -> LOTTIE_BLANCHARD (User)
+ Computers with Shared Path (1): SECWWEBS1000002.TRAINING.LOCAL
+
+ Common Escalation Path: MemberOf -> RO-SIVOYCONL-ADMINGROUP1 (Group) -> GenericAll -> CL-PIRATAS12-DISTLIST1 (Group) -> GenericAll -> JANELL_ALFORD (User)
+
Computers with Shared Path (1): SECWWKS1000000.TRAINING.LOCAL
- Common Escalation Path: GenericAll -> AWSWWKS1000001 (Computer) -> GenericAll -> LAMBERT.LOTTE (User)
+
+ Common Escalation Path: GenericAll -> AWSWWKS1000001 (Computer) -> GenericAll -> JANELL_ALFORD (User)
Computers with Shared Path (1): SQLSERVER10.TRAINING.LOCAL
- Common Escalation Path: GenericWrite -> DC01 (Computer)
- Computers with Shared Path (1): TSTWAPPS1000001.TRAINING.LOCAL
- Common Escalation Path: GenericAll -> JANELL_ALFORD (User)
+ Common Escalation Path: WriteDacl -> DC01 (Computer)
+
+ Computers with Shared Path (1): TSTWDBAS1000000.TRAINING.LOCAL
+
+ Common Escalation Path: GenericAll -> AN-COR-DISTLIST1 (Group) -> MemberOf -> CA-ZID-ADMINGROUP1 (Group) -> GenericAll -> FABIAN_WHITLEY (User)
Computers with Shared Path (1): TSTWLPT1000000.TRAINING.LOCAL
- Common Escalation Path: GenericAll -> MOSES_KRAMER (User)
+ Common Escalation Path: GenericAll -> MOSES_KRAMER (User)
Computers with SMB Signing Disabled and Escalation Paths: 2
▶ LAB-SQL01.TRAINING.LOCAL (WINDOWS SERVER 2019 STANDARD)
- └─ LEADS TO -> SCCMDB.TRAINING.LOCAL (Computer) (via 1 steps)
- └─ LAB-SQL01.TRAINING.LOCAL (Computer) -> LocalAdminRequired -> SCCMDB.TRAINING.LOCAL (Computer)
+ └─ LEADS TO -> SCCMDB.TRAINING.LOCAL (Computer) (via 4 steps)
+ └─ LAB-SQL01.TRAINING.LOCAL (Computer) -> MemberOf -> DOMAIN COMPUTERS@TRAINING.LOCAL (Group) -> MemberOf -> AUTHENTICATED USERS@TRAINING.LOCAL (Group) -> MemberOf -> EVERYONE@TRAINING.LOCAL (Group) -> WriteAccountRestrictions -> SCCMDB.TRAINING.LOCAL (Computer)
+
▶ WINDOWS10.TRAINING.LOCAL (WINDOWS 10 ENTERPRISE LTSC)
- └─ LEADS TO -> SCCMDB.TRAINING.LOCAL (Computer) (via 3 steps)
- └─ WINDOWS10.TRAINING.LOCAL (Computer) -> MemberOf -> DOMAIN COMPUTERS@TRAINING.LOCAL (Group) -> MemberOf -> AUTHENTICATED USERS@TRAINING.LOCAL (Group) -> CoerceAndRelayToSMB -> SCCMDB.TRAINING.LOCAL (Computer)
+ └─ LEADS TO -> SCCMDB.TRAINING.LOCAL (Computer) (via 4 steps)
+ └─ WINDOWS10.TRAINING.LOCAL (Computer) -> MemberOf -> DOMAIN COMPUTERS@TRAINING.LOCAL (Group) -> MemberOf -> AUTHENTICATED USERS@TRAINING.LOCAL (Group) -> MemberOf -> EVERYONE@TRAINING.LOCAL (Group) -> WriteAccountRestrictions -> SCCMDB.TRAINING.LOCAL (Computer)
Computers with WebClient and Escalation Paths: 2
▶ LAB-SQL01.TRAINING.LOCAL (WINDOWS SERVER 2019 STANDARD)
- └─ LEADS TO -> SCCMDB.TRAINING.LOCAL (Computer) (via 1 steps)
- └─ LAB-SQL01.TRAINING.LOCAL (Computer) -> LocalAdminRequired -> SCCMDB.TRAINING.LOCAL (Computer)
+ └─ LEADS TO -> SCCMDB.TRAINING.LOCAL (Computer) (via 4 steps)
+ └─ LAB-SQL01.TRAINING.LOCAL (Computer) -> MemberOf -> DOMAIN COMPUTERS@TRAINING.LOCAL (Group) -> MemberOf -> AUTHENTICATED USERS@TRAINING.LOCAL (Group) -> MemberOf -> EVERYONE@TRAINING.LOCAL (Group) -> WriteAccountRestrictions -> SCCMDB.TRAINING.LOCAL (Computer)
+
▶ WINDOWS10.TRAINING.LOCAL (WINDOWS 10 ENTERPRISE LTSC)
- └─ LEADS TO -> SCCMDB.TRAINING.LOCAL (Computer) (via 3 steps)
- └─ WINDOWS10.TRAINING.LOCAL (Computer) -> MemberOf -> DOMAIN COMPUTERS@TRAINING.LOCAL (Group) -> MemberOf -> AUTHENTICATED USERS@TRAINING.LOCAL (Group) -> CoerceAndRelayToSMB -> SCCMDB.TRAINING.LOCAL (Computer)
+ └─ LEADS TO -> SCCMDB.TRAINING.LOCAL (Computer) (via 4 steps)
+ └─ WINDOWS10.TRAINING.LOCAL (Computer) -> MemberOf -> DOMAIN COMPUTERS@TRAINING.LOCAL (Group) -> MemberOf -> AUTHENTICATED USERS@TRAINING.LOCAL (Group) -> MemberOf -> EVERYONE@TRAINING.LOCAL (Group) -> WriteAccountRestrictions -> SCCMDB.TRAINING.LOCAL (Computer)
Computer with Unconstrained Delegation: 1
▶ SCCM.TRAINING.LOCAL
@@ -265,6 +324,7 @@ High Risk Profiles:
ESC2 — Any Purpose EKU: 2
▶ ESC2 (TRAINING-DC01-CA ON DC01.TRAINING.LOCAL)
▶ AUTHENTICATED USERS, DOMAIN USERS
+
▶ ESC3 (TRAINING-DC01-CA ON DC01.TRAINING.LOCAL)
▶ AUTHENTICATED USERS, DOMAIN USERS
@@ -279,119 +339,124 @@ High Risk Profiles:
▶ ESC9 (TRAINING-DC01-CA ON DC01.TRAINING.LOCAL)
▶ DOMAIN USERS
- MSSQL Privilege Escalation: 1
- ▶ LAB-SQL01.TRAINING.LOCAL
- ▶ LAB-SQL01$ -> MSSQL_MemberOf -> sysadmin on sccmdb.training.local
- ▶ LAB-SQL01$ -> MSSQL_IsMappedTo -> LAB-SQL01$ -> MSSQL_MemberOf -> db_owner (CM_TRN) on sccmdb.training.local
- ▶ LAB-SQL01$ -> MSSQL_MemberOf -> sysadmin -> MSSQL_ControlServer -> SCCMDB.TRAINING.LOCAL -> MSSQL_ExecuteOnHost -> sccmdb.training.local
+ MSSQL Privilege Escalation: 7
+ ▶ J.REPORTER
+ j.reporter > MSSQL_ExecuteAs > ReportSvc > MSSQL_ExecuteAs > ETLService > MSSQL_ExecuteAs > sa > MSSQL_MemberOf > sysadmin@lab-sql01.training.local
+ j.reporter > MSSQL_ExecuteAs > ReportSvc > MSSQL_MemberOf > DataAdmin > MSSQL_ChangePassword > DevDeploy@lab-sql01.training.local (ALTER ANY LOGIN)
+ j.reporter > MSSQL_ExecuteAs > ReportSvc > MSSQL_MemberOf > DataAdmin > MSSQL_ChangePassword > WebAppPool@lab-sql01.training.local (ALTER ANY SERVER ROLE)
+ j.reporter > MSSQL_ExecuteAs > ReportSvc > MSSQL_MemberOf > DataAdmin > MSSQL_ChangePassword > DevDeploy > MSSQL_IsMappedTo > TestUser2@VulnerableApp (VulnerableApp)
+ j.reporter > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > sysadmin@sccmdb.training.local
- MSSQL Server Vulnerable to NTLM Relay: 1
- ▶ LAB-SQL01.TRAINING.LOCAL
- ▶ DOMAIN USERS@TRAINING.LOCAL (all domain users) -> xp_dirtree coercion (DA@TRAINING.LOCAL) -> Relay to SCCMDB.TRAINING.LOCAL (WINDOWS SERVER 2022 STANDARD), WINDOWS10.TRAINING.LOCAL (WINDOWS 10 ENTERPRISE LTSC)
-
- Non-admin AS-REP Roastable with Weak Password: 4
- ▶ CHRISTAL.EUGENIA
- ▶ LEIGH.PENNIE
- ▶ MARNIA.CHRISSIE
- ▶ VICTORIA.REP
+ ▶ JOE.PATHFINDER
+ joe.pathfinder > MSSQL_ExecuteAs > sqlsvc@lab-sql01.training.local (ALTER ANY SERVER ROLE)
+ joe.pathfinder > MSSQL_ExecuteAs > sqlsvc > MSSQL_AddMember > DataAdmin > MSSQL_ChangePassword > DevDeploy@lab-sql01.training.local (ALTER ANY LOGIN)
+ joe.pathfinder > MSSQL_ExecuteAs > sqlsvc > MSSQL_AddMember > DataAdmin > MSSQL_ChangePassword > WebAppPool@lab-sql01.training.local (ALTER ANY SERVER ROLE)
+ joe.pathfinder > MSSQL_ExecuteAs > sqlsvc > MSSQL_AddMember > DataAdmin > MSSQL_ChangePassword > DevDeploy > MSSQL_IsMappedTo > TestUser2@VulnerableApp (VulnerableApp)
+ joe.pathfinder > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > sysadmin@sccmdb.training.local
- Non-admin Kerberoastable with Weak Password: 1
▶ SQLSVC
+ sqlsvc > MSSQL_AddMember > DataAdmin > MSSQL_ChangePassword > DevDeploy@lab-sql01.training.local (ALTER ANY LOGIN)
+ sqlsvc > MSSQL_AddMember > DataAdmin > MSSQL_ChangePassword > WebAppPool@lab-sql01.training.local (ALTER ANY SERVER ROLE)
+ sqlsvc > MSSQL_AddMember > DataAdmin > MSSQL_ChangePassword > DevDeploy > MSSQL_IsMappedTo > TestUser2@VulnerableApp (VulnerableApp)
+ sqlsvc > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > sysadmin@sccmdb.training.local
+
+ ▶ DOMAIN USERS@TRAINING.LOCAL
+ DOMAIN USERS > MSSQL_HasLogin > Domain Users > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > sysadmin@sccmdb.training.local
+
+ ▶ WELCOMEUSER1
+ welcomeuser1 > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > sysadmin@sccmdb.training.local
+
+ ▶ UNCONSTRAINED
+ unconstrained > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > sysadmin@sccmdb.training.local
+
+ ▶ DCSYNC
+ dcsync > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > sysadmin@sccmdb.training.local
+
+ MSSQL Server Vulnerable to NTLM Relay: 2
+ ▶ SCCMDB.TRAINING.LOCAL
+ ▶ sccmdb\ConfigMgr_DViewAccess > MSSQL_Connect > SCCMDB.TRAINING.LOCAL > xp_dirtree > DA@TRAINING.LOCAL > NTLM_Relay > WINDOWS10.TRAINING.LOCAL (WINDOWS 10 ENTERPRISE LTSC), LAB-SQL01.TRAINING.LOCAL (WINDOWS SERVER 2019 STANDARD)
+ ▶ TRAINING\LAB-SQL01$ > MSSQL_Connect > SCCMDB.TRAINING.LOCAL > xp_dirtree > DA@TRAINING.LOCAL > NTLM_Relay > WINDOWS10.TRAINING.LOCAL (WINDOWS 10 ENTERPRISE LTSC), LAB-SQL01.TRAINING.LOCAL (WINDOWS SERVER 2019 STANDARD)
+
+ ▶ LAB-SQL01.TRAINING.LOCAL
+ ▶ DOMAIN USERS@TRAINING.LOCAL > MSSQL_Connect > LAB-SQL01.TRAINING.LOCAL > xp_dirtree > DA@TRAINING.LOCAL > NTLM_Relay > SCCMDB.TRAINING.LOCAL (WINDOWS SERVER 2022 STANDARD), WINDOWS10.TRAINING.LOCAL (WINDOWS 10 ENTERPRISE LTSC)
+ ▶ LAB-SQL01\WSUS Administrators > MSSQL_Connect > LAB-SQL01.TRAINING.LOCAL > xp_dirtree > DA@TRAINING.LOCAL > NTLM_Relay > SCCMDB.TRAINING.LOCAL (WINDOWS SERVER 2022 STANDARD), WINDOWS10.TRAINING.LOCAL (WINDOWS 10 ENTERPRISE LTSC)
SCCM Infrastructure SMB Relay (ELEVATE-1): 1
▶ SCCMDB.TRAINING.LOCAL
- ▶ Coerce lab-sql01.training.local -> SMB relay to sccmdb.training.local (SMS Component Server, SMS SQL Server, SMS Provider) (Site TRN)
+ ▶ Coerce lab-sql01.training.local -> SMB relay to sccmdb.training.local (SMS SQL Server, SMS Component Server, SMS Provider, SMS Site System) (Site TRN)
User with Unconstrained Delegation: 1
▶ UNCONSTRAINED
Medium Risk Profiles:
- Account with Shared Password: 21
- ▶ ADCS
- ▶ ADMINISTRATOR
- ▶ ANNETTE.LEON
- ▶ BUFFY.RUTHANNE
- ▶ CAL.EM
- ▶ CHRISTAL.EUGENIA
- ▶ DA
- ▶ DOMAINNAMEASUSER
- ▶ JANNELLE.LAIRD
- ▶ JB
- ▶ JOE.PATHFINDER
- ▶ JOE.SMITH
- ▶ JOJO.KATRINA
- ▶ KORNEY.DARELLE
- ▶ LOTTI.RENAE
- ▶ MAURINE.CAMMIE
- ▶ PERLE.SELIE
- ▶ ROBINA.KIPPAR
- ▶ SABINE.MYRTIE
- ▶ TESTDA
- ▶ UNCONSTRAINED
-
Computer with Constrained Delegation: 1
▶ KIOSK01.TRAINING.LOCAL
▶ Constrained Delegation: http/JENKINS.training.local, http/JENKINS, HOST/JENKINS.training.local, HOST/JENKINS, cifs/JENKINS.training.local, cifs/JENKINS
Computers with SMB Signing Disabled: 3
▶ LAB-SQL01.TRAINING.LOCAL (WINDOWS SERVER 2019 STANDARD)
+
▶ SCCMDB.TRAINING.LOCAL (WINDOWS SERVER 2022 STANDARD)
+
▶ WINDOWS10.TRAINING.LOCAL (WINDOWS 10 ENTERPRISE LTSC)
KRBTGT Password Older Than 6 Months: 1
▶ KRBTGT
▶ Last Changed On: 11/29/2023
- MSSQL Login: 2
- ▶ LAB-SQL01.TRAINING.LOCAL
- └─ SQL Login on SCCMDB.training.local
- └─ Database: CM_TRN
- ├─ Roles: db_owner
- └─ ATTACK PATH: Full database control
+ MSSQL Login: 7
+ ▶ SQLSVC
+ └─ SQL Login on lab-sql01.training.local:1433
+ └─ No database access
+
+ ▶ JOE.PATHFINDER
+ └─ SQL Login on lab-sql01.training.local:1433
+ └─ Database: VulnerableApp
+ ├─ Roles: db_owner, public
+ └─ Permissions: CONNECT
+
+ ▶ WELCOMEUSER1
+ └─ SQL Login on lab-sql01.training.local:1433
+ └─ No database access
+
+ ▶ UNCONSTRAINED
+ └─ SQL Login on lab-sql01.training.local:1433
+ └─ No database access
+
+ ▶ DCSYNC
+ └─ SQL Login on lab-sql01.training.local:1433
+ └─ No database access
+
+ ▶ J.REPORTER
+ └─ SQL Login on lab-sql01.training.local:1433
+ └─ Database: Reporting
+ ├─ Roles: db_datareader, public, report_executor
+ └─ Permissions: CONNECT
+
▶ DOMAIN USERS@TRAINING.LOCAL
└─ SQL Login on lab-sql01.training.local:1433
└─ Database: VulnerableApp
├─ Roles: public
└─ Permissions: CONNECT, DELETE, INSERT, SELECT, UPDATE
- Non-admin with Weak Password: 31
- ▶ ADCS
- ▶ AERIELL.SANDY
- ▶ ANICA.PAULINA
- ▶ ANNADIANE.FAE
- ▶ ANNETTE.LEON
- ▶ BOARDROOM
- ▶ BUFFY.RUTHANNE
- ▶ CAL.EM
- ▶ CHERILYNN.ROSELIA
- ▶ CHRISTA.JACQUENETTA
- ▶ CHRISTAL.EUGENIA
- ▶ CISCO
- ▶ COMPUTER01
- ▶ DEBBIE.KISSIE
- ▶ DEMETRA.MYRTIA
- ▶ DOMAINNAMEASUSER
- ▶ JANNELLE.LAIRD
- ▶ JOE.PATHFINDER
- ▶ JOE.SMITH
- ▶ JOJO.KATRINA
- ▶ LEIGH.PENNIE
- ▶ LOTTI.RENAE
- ▶ MARNIA.CHRISSIE
- ▶ MAURINE.CAMMIE
- ▶ PERLE.SELIE
- ▶ ROBINA.KIPPAR
- ▶ SABINE.MYRTIE
- ▶ SQLSVC
- ▶ TEST.USERS
- ▶ UNCONSTRAINED
- ▶ VICTORIA.REP
+ MSSQL Login Impersonation: 1
+ ▶ LAB-SQL01.TRAINING.LOCAL
+ ▶ ETLService -> EXECUTE AS sa
+ ▶ ReportSvc -> EXECUTE AS ETLService -> EXECUTE AS sa
+ ▶ ReportSvc -> EXECUTE AS TRAINING\Administrator
+ ▶ TRAINING\j.reporter -> EXECUTE AS ReportSvc -> EXECUTE AS TRAINING\Administrator
+ ▶ TRAINING\joe.pathfinder -> EXECUTE AS TRAINING\sqlsvc
+ ▶ TRAINING\unconstrained -> EXECUTE AS TRAINING\dcsync
SCCM Management Point - Client Policy Retrieval Review: 1
▶ LAB-SQL01.TRAINING.LOCAL
- ▶ lab-sql01.training.local (Site TRN) - client certificate requirement not observed - review http://lab-sql01.training.local/ccm_system/request
+ ▶ lab-sql01.training.local (Site TRN) - review http(s)://lab-sql01.training.local/ccm_system/request
Low Risk Profiles:
-Info Risk Profiles:
+ Tier-0 Session on Non-Tier-0 Host: 1
+ ▶ DA
+ ▶ Active session on: LAB-SQL01.TRAINING.LOCAL
+
+Info Risk Profiles:
\ No newline at end of file
diff --git a/tests/README.md b/tests/README.md
index a9c21b0..a781e87 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -1,11 +1,11 @@
# Tests
-Two test surfaces ship in this repo:
+Two main test surfaces ship in this repo:
-1. **Framework unit tests** at `tests/test_*.py` — mock-based, no Neo4j required.
-2. **Per-check fixture tests** at `tests/checks/test_*.py` — load Cypher fixtures into a real Neo4j and run a single check against the resulting graph. Marked `@pytest.mark.neo4j`.
+1. **Framework unit tests** — mock-based, no Neo4j required.
+2. **Neo4j fixture tests** — load Cypher fixtures into a real Neo4j. They are selected by the `@pytest.mark.neo4j` marker and may live anywhere under `tests/`.
-Both suites run on every PR via CI. Framework tests run across the Python 3.9/3.13 matrix; fixture tests run on 3.13 only against a `neo4j:5-community` service container.
+Both suites run on every PR via CI. Framework tests run across the Python 3.9/3.13 matrix; Neo4j fixture tests run on 3.13 only against a `neo4j:5-community` service container.
## Running the fast suite (no Docker)
@@ -41,7 +41,7 @@ Run the suite:
ADPF_TEST_ALLOW_WIPE=1 \
NEO4J_URI=bolt://localhost:17687 \
NEO4J_USER=neo4j NEO4J_PASSWORD=testpassword \
-pytest tests/checks -m "neo4j and not integration" -v
+pytest tests/ -m "neo4j and not integration" -v
```
Tear down:
@@ -53,7 +53,7 @@ docker rm -f adpf-test-neo4j
## Adding a test for a new check
1. Create `tests/fixtures/.cypher` with a minimal graph that triggers your check. Look at `tests/fixtures/esc3_enrollment_agent.cypher` for the pattern.
-2. Create `tests/checks/test_.py` starting with `import pytest` and `pytestmark = pytest.mark.neo4j` at module top, then load the fixture, run the check via `run_check(YourCheck, clean_neo4j)`, and assert the findings. The marker is mandatory — without it the test runs in the fast lane and trips the wipe guard.
+2. Create `tests/checks/test_.py` starting with `import pytest` and `pytestmark = pytest.mark.neo4j` at module top, then load the fixture, run the check via `run_check(YourCheck, clean_neo4j)`, and assert the findings. The marker is mandatory — without it the test runs in the fast lane and trips the wipe guard. Non-check Neo4j tests may live elsewhere under `tests/`, but they must use the same marker.
3. Optionally add a negative fixture (`_negative.cypher`) and assert the check stays silent.
4. Run locally as shown above.
diff --git a/tests/checks/test_manager_smoke.py b/tests/checks/test_manager_smoke.py
index 85c14d7..a74b993 100644
--- a/tests/checks/test_manager_smoke.py
+++ b/tests/checks/test_manager_smoke.py
@@ -70,11 +70,11 @@ def test_sccm_takeover6_visible_to_manager(clean_neo4j):
assert not check_errors, f"Unexpected check errors: {check_errors}"
-def test_mssql_sccm_visible_to_manager(clean_neo4j):
+def test_sccm_privilege_escalation_visible_to_manager(clean_neo4j):
display_content, stats_content, check_errors = _run_manager(
- clean_neo4j, fixture="mssql_sccm.cypher",
+ clean_neo4j, fixture="sccm_privilege_escalation.cypher",
)
- category = "SCCM Database Compromise"
+ category = "SCCM Privilege Escalation"
block = _find_block(display_content, category)
_assert_block_structure(block, category)
_assert_stats_mirror(display_content, stats_content, category)
diff --git a/tests/checks/test_mssql_logins.py b/tests/checks/test_mssql_logins.py
index e271051..4734902 100644
--- a/tests/checks/test_mssql_logins.py
+++ b/tests/checks/test_mssql_logins.py
@@ -16,7 +16,9 @@ def test_fires_on_non_admin_user_with_db_control(clean_neo4j):
assert isinstance(detail, str)
assert "sql01.test.local:1433" in detail
assert "AppDB" in detail
+ assert "ATTACK PATH: Full database control" not in detail
assert "S-1-5-21-TEST-513" in findings
group_detail = findings["S-1-5-21-TEST-513"]
assert isinstance(group_detail, str)
assert "sql01.test.local:1433" in group_detail
+ assert "ATTACK PATH: Full database control" not in group_detail
diff --git a/tests/checks/test_mssql_privilege_escalation.py b/tests/checks/test_mssql_privilege_escalation.py
index 85b798a..025737f 100644
--- a/tests/checks/test_mssql_privilege_escalation.py
+++ b/tests/checks/test_mssql_privilege_escalation.py
@@ -19,3 +19,109 @@ def test_fires_on_login_to_sysadmin_path(clean_neo4j):
assert isinstance(detail, str)
assert "sysadmin" in detail.lower()
assert "sql01.test.local" in detail
+
+
+def test_fires_on_linked_server_path_with_case_mismatched_bridge(clean_neo4j):
+ load_fixture(clean_neo4j, "mssql_privilege_escalation_linked_server.cypher")
+
+ findings = run_check(MSSQLPrivilegeEscalationCheck, clean_neo4j, domain_filter="TEST.LOCAL")
+ user_sid = "S-1-5-21-TEST-3201"
+ assert user_sid in findings
+ detail = findings[user_sid]
+ assert "MSSQL_LinkedAsAdmin" in detail
+ assert "sysadmin" in detail.lower()
+ assert "sccmdb.test.local" in detail
+
+
+def test_fires_on_group_owned_linked_server_path(clean_neo4j):
+ load_fixture(clean_neo4j, "mssql_privilege_escalation_linked_server.cypher")
+
+ findings = run_check(MSSQLPrivilegeEscalationCheck, clean_neo4j, domain_filter="TEST.LOCAL")
+ user_sid = "S-1-5-21-TEST-3202"
+ assert user_sid in findings
+ detail = findings[user_sid]
+ assert "linked-group" in detail
+ assert "MSSQL_LinkedAsAdmin" in detail
+ assert "sysadmin" in detail.lower()
+
+
+def test_common_group_owned_linked_server_reports_group_not_members(clean_neo4j):
+ load_fixture(clean_neo4j, "mssql_privilege_escalation_linked_server.cypher")
+
+ findings = run_check(MSSQLPrivilegeEscalationCheck, clean_neo4j, domain_filter="TEST.LOCAL")
+ assert "S-1-5-21-TEST-513" in findings
+ assert "S-1-5-21-TEST-3203" not in findings
+ detail = findings["S-1-5-21-TEST-513"]
+ assert "DOMAIN USERS" in detail
+ assert "DOMAIN USERS -> MSSQL_HasLogin -> Domain Users -> MSSQL_Connect" in detail
+ assert "MSSQL_LinkedAsAdmin" in detail
+ assert "sysadmin" in detail.lower()
+
+
+def test_fires_on_linked_server_with_sid_based_stub(clean_neo4j):
+ load_fixture(clean_neo4j, "mssql_priv_esc_linked_server_sid.cypher")
+
+ findings = run_check(MSSQLPrivilegeEscalationCheck, clean_neo4j, domain_filter="TEST.LOCAL")
+ user_sid = "S-1-5-21-TEST-3301"
+ assert user_sid in findings
+ detail = findings[user_sid]
+ assert "MSSQL_LinkedAsAdmin" in detail
+ assert "sysadmin" in detail.lower()
+ assert "sccmdb.test.local" in detail
+
+
+def test_fires_when_login_owns_high_value_server_role(clean_neo4j):
+ load_fixture(clean_neo4j, "mssql_priv_esc_owns_server_role.cypher")
+
+ findings = run_check(MSSQLPrivilegeEscalationCheck, clean_neo4j, domain_filter="TEST.LOCAL")
+ user_sid = "S-1-5-21-TEST-3601"
+ assert user_sid in findings
+ detail = findings[user_sid]
+ assert "MSSQL_Owns" in detail
+ assert "sysadmin" in detail.lower()
+
+
+def test_does_not_fire_when_stub_name_only_substring_matches_sqlservername(clean_neo4j):
+ load_fixture(clean_neo4j, "mssql_priv_esc_linked_server_overmatch.cypher")
+
+ findings = run_check(MSSQLPrivilegeEscalationCheck, clean_neo4j, domain_filter="TEST.LOCAL")
+ assert findings == {}
+
+
+def test_fires_on_group_owned_login_through_nested_memberof_depth_6(clean_neo4j):
+ load_fixture(clean_neo4j, "mssql_priv_esc_group_owned_login.cypher")
+
+ findings = run_check(MSSQLPrivilegeEscalationCheck, clean_neo4j, domain_filter="TEST.LOCAL")
+ user_sid = "S-1-5-21-TEST-3401"
+ assert user_sid in findings
+ detail = findings[user_sid]
+ assert "group-login" in detail
+ assert "NESTEDUSER -> MemberOf -> G1 -> MemberOf -> G2" in detail
+ assert "SQL OWNERS -> MSSQL_HasLogin -> group-login" in detail
+ assert "@TEST.LOCAL" not in detail
+ assert "sysadmin" in detail.lower()
+
+
+def test_linked_server_target_does_not_overmatch_sibling_host(clean_neo4j):
+ load_fixture(clean_neo4j, "mssql_priv_esc_linked_server_name_overmatch.cypher")
+
+ findings = run_check(MSSQLPrivilegeEscalationCheck, clean_neo4j, domain_filter="TEST.LOCAL")
+ user_sid = "S-1-5-21-TEST-3701"
+ assert user_sid in findings
+ detail = findings[user_sid]
+ assert "sql01.test.local" in detail
+ assert "sql01b" not in detail
+
+
+def test_linked_server_empty_target_stub_resolves_to_nothing(clean_neo4j):
+ load_fixture(clean_neo4j, "mssql_priv_esc_linked_server_empty_stub.cypher")
+
+ findings = run_check(MSSQLPrivilegeEscalationCheck, clean_neo4j, domain_filter="TEST.LOCAL")
+ assert findings == {}
+
+
+def test_linked_server_shared_host_sid_bridges_to_no_instance(clean_neo4j):
+ load_fixture(clean_neo4j, "mssql_priv_esc_linked_server_shared_host_sid.cypher")
+
+ findings = run_check(MSSQLPrivilegeEscalationCheck, clean_neo4j, domain_filter="TEST.LOCAL")
+ assert findings == {}
diff --git a/tests/checks/test_mssql_sccm.py b/tests/checks/test_mssql_sccm.py
deleted file mode 100644
index dd62005..0000000
--- a/tests/checks/test_mssql_sccm.py
+++ /dev/null
@@ -1,27 +0,0 @@
-import pytest
-
-from checks.mssql_sccm import MSSQLSCCMCheck
-from tests.check_harness import load_fixture, run_check
-
-pytestmark = pytest.mark.neo4j
-
-
-def test_fires_on_cross_schema_path_to_sccm_site(clean_neo4j):
- load_fixture(clean_neo4j, "mssql_sccm.cypher")
-
- sccm_rows = clean_neo4j.query(
- "MATCH (n:SCCM_Site) WHERE toUpper(n.sourceForest) = 'TEST.LOCAL' RETURN count(n) AS c"
- )
- assert sccm_rows and sccm_rows[0]['c'] > 0
- mssql_rows = clean_neo4j.query(
- "MATCH (n:MSSQL_Server) WHERE toUpper(n.name) CONTAINS '.TEST.LOCAL:' RETURN count(n) AS c"
- )
- assert mssql_rows and mssql_rows[0]['c'] > 0
-
- findings = run_check(MSSQLSCCMCheck, clean_neo4j, domain_filter="TEST.LOCAL")
- assert MSSQLSCCMCheck.RISK_LEVEL == "Critical"
- assert len(findings) >= 1
- assert "S-1-5-21-TEST-1300" in findings
- assert isinstance(findings["S-1-5-21-TEST-1300"], str)
- assert "Base" not in findings["S-1-5-21-TEST-1300"]
- assert "SCCM_Site" in findings["S-1-5-21-TEST-1300"]
diff --git a/tests/checks/test_sccm_privilege_escalation.py b/tests/checks/test_sccm_privilege_escalation.py
new file mode 100644
index 0000000..5a650d9
--- /dev/null
+++ b/tests/checks/test_sccm_privilege_escalation.py
@@ -0,0 +1,178 @@
+import pytest
+
+from checks.core import DisplayTypes
+from checks.sccm_privilege_escalation import SCCMPrivilegeEscalationCheck
+from tests.check_harness import load_fixture, run_check
+
+pytestmark = pytest.mark.neo4j
+
+
+def _run_sccm_check(conn):
+ load_fixture(conn, "sccm_privilege_escalation.cypher")
+ return run_check(SCCMPrivilegeEscalationCheck, conn, domain_filter="TEST.LOCAL")
+
+
+def test_fires_on_ad_group_to_sccm_site(clean_neo4j):
+ findings = _run_sccm_check(clean_neo4j)
+
+ user_sid = "S-1-5-21-TEST-100"
+ assert SCCMPrivilegeEscalationCheck.RISK_LEVEL == "Critical"
+ assert user_sid in findings
+ detail = findings[user_sid]
+ assert "SCCM_IsMappedTo" in detail
+ assert "SCCM_AllPermissions" in detail
+ assert "P01" in detail
+ assert "Base" not in detail
+
+
+def test_fires_on_nested_ad_group_to_sccm_site_depth_6(clean_neo4j):
+ findings = _run_sccm_check(clean_neo4j)
+
+ user_sid = "S-1-5-21-TEST-101"
+ assert user_sid in findings
+ detail = findings[user_sid]
+ assert "NESTED -> MemberOf -> N1 -> MemberOf -> N2" in detail
+ assert "@TEST.LOCAL" not in detail
+ assert "SCCM_AllPermissions" in detail
+
+
+def test_fires_on_priv_esc_to_sccm_server_sysadmin(clean_neo4j):
+ findings = _run_sccm_check(clean_neo4j)
+
+ user_sid = "S-1-5-21-TEST-102"
+ assert user_sid in findings
+ detail = findings[user_sid]
+ assert "sysadmin" in detail.lower()
+ assert "MSSQL_Database(CM_P01) -> SCCM_AssignAllPermissions -> SCCM_Site(P01)" in detail
+
+
+def test_fires_on_priv_esc_to_sccm_database_role(clean_neo4j):
+ findings = _run_sccm_check(clean_neo4j)
+
+ user_sid = "S-1-5-21-TEST-103"
+ assert user_sid in findings
+ detail = findings[user_sid]
+ assert "db_owner@CM_P01" in detail
+ assert "MSSQL_Database(CM_P01) -> SCCM_AssignAllPermissions -> SCCM_Site(P01)" in detail
+
+
+def test_fires_on_linked_server_to_sccm_target(clean_neo4j):
+ findings = _run_sccm_check(clean_neo4j)
+
+ user_sid = "S-1-5-21-TEST-104"
+ assert user_sid in findings
+ detail = findings[user_sid]
+ assert "MSSQL_LinkedAsAdmin" in detail
+ assert "MSSQL_Database(CM_P01) -> SCCM_AssignAllPermissions -> SCCM_Site(P01)" in detail
+
+
+def test_fires_on_linked_execute_host_to_sccm_site(clean_neo4j):
+ findings = _run_sccm_check(clean_neo4j)
+
+ user_sid = "S-1-5-21-TEST-109"
+ assert user_sid in findings
+ detail = findings[user_sid]
+ assert "EXECCHAIN -> MSSQL_HasLogin -> execchain -> MSSQL_ExecuteAs -> ReportSvc" in detail
+ assert "MSSQL_LinkedAsAdmin" in detail
+ assert "MSSQL_ExecuteOnHost (as SQLSVC@TEST.LOCAL)" in detail
+ assert detail.count("MSSQL_ExecuteOnHost") == 1
+ assert "SCCMSQL.TEST.LOCAL -> SCCM_AssignAllPermissions -> SCCM_Site(P01)" in detail
+
+
+def test_fires_on_group_owned_linked_server_to_sccm_target(clean_neo4j):
+ findings = _run_sccm_check(clean_neo4j)
+
+ user_sid = "S-1-5-21-TEST-107"
+ assert user_sid in findings
+ detail = findings[user_sid]
+ assert "GROUPLINKED -> MemberOf -> LINKED SQL USERS -> MSSQL_HasLogin" in detail
+ assert "linked-group" in detail
+ assert "GROUPLINKED@TEST.LOCAL" not in detail
+ assert "LINKED SQL USERS@TEST.LOCAL" not in detail
+ assert "MSSQL_LinkedAsAdmin" in detail
+ assert "MSSQL_Database(CM_P01) -> SCCM_AssignAllPermissions -> SCCM_Site(P01)" in detail
+
+
+def test_common_group_owned_linked_server_to_sccm_reports_group_not_members(clean_neo4j):
+ findings = _run_sccm_check(clean_neo4j)
+
+ assert SCCMPrivilegeEscalationCheck.DISPLAY_TYPE == DisplayTypes.SHARED_GRAPH_PATHS
+ assert "S-1-5-21-TEST-513" in findings
+ assert "S-1-5-21-TEST-108" not in findings
+ detail = findings["S-1-5-21-TEST-513"]
+ assert "DOMAIN USERS" in detail
+ assert "DOMAIN USERS -> MSSQL_HasLogin -> Domain Users -> MSSQL_Connect" in detail
+ assert "MSSQL_LinkedAsAdmin" in detail
+ assert "MSSQL_Database(CM_P01) -> SCCM_AssignAllPermissions -> SCCM_Site(P01)" in detail
+
+
+def test_fires_on_alter_any_login_to_sccm_server(clean_neo4j):
+ findings = _run_sccm_check(clean_neo4j)
+
+ user_sid = "S-1-5-21-TEST-105"
+ assert user_sid in findings
+ detail = findings[user_sid]
+ assert "MSSQL_AlterAnyLogin" in detail
+ assert "MSSQL_Database(CM_P01) -> SCCM_AssignAllPermissions -> SCCM_Site(P01)" in detail
+
+
+def test_fires_on_admin_user_with_full_administrator_role(clean_neo4j):
+ findings = _run_sccm_check(clean_neo4j)
+
+ user_sid = "S-1-5-21-TEST-106"
+ assert user_sid in findings
+ detail = findings[user_sid]
+ assert "SCCM_IsAssigned" in detail
+ assert "Full Administrator" in detail
+
+
+def test_no_finding_for_connect_only_login_on_sccm_server(clean_neo4j):
+ findings = _run_sccm_check(clean_neo4j)
+ assert "S-1-5-21-TEST-200" not in findings
+
+
+def test_no_finding_for_linked_execute_host_on_disabled_sccm_computer(clean_neo4j):
+ findings = _run_sccm_check(clean_neo4j)
+ assert "S-1-5-21-TEST-203" not in findings
+
+
+def test_no_finding_for_linked_execute_host_without_remote_admin(clean_neo4j):
+ findings = _run_sccm_check(clean_neo4j)
+ assert "S-1-5-21-TEST-204" not in findings
+
+
+def test_no_finding_for_non_admin_group_admin_user(clean_neo4j):
+ findings = _run_sccm_check(clean_neo4j)
+ assert "S-1-5-21-TEST-201" not in findings
+
+
+def test_no_cross_forest_sccm_target_when_domain_filter_set(clean_neo4j):
+ findings = _run_sccm_check(clean_neo4j)
+ assert "S-1-5-21-TEST-202" not in findings
+
+
+def test_linked_server_target_does_not_overmatch_sibling_host(clean_neo4j):
+ load_fixture(clean_neo4j, "sccm_priv_esc_linked_server_name_overmatch.cypher")
+
+ findings = run_check(SCCMPrivilegeEscalationCheck, clean_neo4j, domain_filter="TEST.LOCAL")
+ user_sid = "S-1-5-21-TEST-310"
+ assert user_sid in findings
+ detail = findings[user_sid]
+ assert "cmsql.test.local" in detail
+ assert "CM_P10" in detail
+ assert "cmsql-dr" not in detail
+ assert "CM_DR" not in detail
+
+
+def test_linked_server_empty_target_stub_resolves_to_nothing(clean_neo4j):
+ load_fixture(clean_neo4j, "sccm_priv_esc_linked_server_empty_stub.cypher")
+
+ findings = run_check(SCCMPrivilegeEscalationCheck, clean_neo4j, domain_filter="TEST.LOCAL")
+ assert "S-1-5-21-TEST-311" not in findings
+
+
+def test_linked_server_shared_host_sid_bridges_to_no_instance(clean_neo4j):
+ load_fixture(clean_neo4j, "sccm_priv_esc_linked_server_shared_host_sid.cypher")
+
+ findings = run_check(SCCMPrivilegeEscalationCheck, clean_neo4j, domain_filter="TEST.LOCAL")
+ assert "S-1-5-21-TEST-312" not in findings
diff --git a/tests/conftest.py b/tests/conftest.py
index cdc2ff8..cb40ba9 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -12,6 +12,29 @@
from modules.neo4j_connection import Neo4jConnection
+DEFAULT_NEO4J_TEST_URI = "bolt://localhost:17687"
+DEFAULT_NEO4J_TEST_PASSWORD = "testpassword"
+
+
+def _delete_in_batches(conn, cypher, batch_size=500):
+ while True:
+ rows = conn.query(cypher, parameters={"batch_size": batch_size})
+ deleted = rows[0].get("deleted", 0) if rows else 0
+ if not deleted:
+ break
+
+
+def _wipe_neo4j(conn):
+ _delete_in_batches(
+ conn,
+ "MATCH ()-[r]->() WITH r LIMIT $batch_size DELETE r RETURN count(*) AS deleted",
+ )
+ _delete_in_batches(
+ conn,
+ "MATCH (n) WITH n LIMIT $batch_size DELETE n RETURN count(*) AS deleted",
+ )
+
+
def _bloodhound_default_port(uri):
try:
parsed = urlparse(uri)
@@ -30,7 +53,7 @@ def neo4j_conn():
"Set the env var only when NEO4J_URI points at a disposable test instance."
)
- uri = os.environ.get("NEO4J_URI", "bolt://localhost:17687")
+ uri = os.environ.get("NEO4J_URI", DEFAULT_NEO4J_TEST_URI)
if _bloodhound_default_port(uri):
in_ci = os.environ.get("GITHUB_ACTIONS") == "true"
@@ -43,7 +66,7 @@ def neo4j_conn():
)
user = os.environ.get("NEO4J_USER", "neo4j")
- password = os.environ.get("NEO4J_PASSWORD", "testpassword")
+ password = os.environ.get("NEO4J_PASSWORD", DEFAULT_NEO4J_TEST_PASSWORD)
conn = Neo4jConnection(uri, user, password)
assert conn.is_connected(), f"Neo4j not reachable at {uri}"
yield conn
@@ -52,6 +75,6 @@ def neo4j_conn():
@pytest.fixture
def clean_neo4j(neo4j_conn):
- neo4j_conn.query("MATCH (n) DETACH DELETE n")
+ _wipe_neo4j(neo4j_conn)
yield neo4j_conn
- neo4j_conn.query("MATCH (n) DETACH DELETE n")
+ _wipe_neo4j(neo4j_conn)
diff --git a/tests/fixtures/canonicalize_linked_server.cypher b/tests/fixtures/canonicalize_linked_server.cypher
new file mode 100644
index 0000000..68db119
--- /dev/null
+++ b/tests/fixtures/canonicalize_linked_server.cypher
@@ -0,0 +1,34 @@
+// Stub-owned linked-server edges are copied to their canonical MSSQL_Server.
+
+CREATE (sourceAdmin:MSSQL_Server:MSSQL_Base:Base {
+ name: 'src-admin.test.local:1433',
+ objectid: 'S-1-5-21-TEST-CANON-A:1433'
+});
+CREATE (stubAdmin:MSSQL_Base:Base {
+ name: 'S-1-5-21-TEST-CANON-A:LINKED',
+ objectid: 'S-1-5-21-TEST-CANON-A:LINKED'
+});
+CREATE (targetAdmin:MSSQL_Server:MSSQL_Base:Base {
+ name: 'linked-admin.test.local:1433',
+ objectid: 'S-1-5-21-TEST-TGT-A:1433'
+});
+
+CREATE (sourceLink:MSSQL_Server:MSSQL_Base:Base {
+ name: 'src-link.test.local:1433',
+ objectid: 'S-1-5-21-TEST-CANON-B:1433'
+});
+CREATE (stubLink:MSSQL_Base:Base {
+ name: 'S-1-5-21-TEST-CANON-B:LINKED',
+ objectid: 'S-1-5-21-TEST-CANON-B:LINKED'
+});
+CREATE (targetLink:MSSQL_Server:MSSQL_Base:Base {
+ name: 'linked-to.test.local:1433',
+ objectid: 'S-1-5-21-TEST-TGT-B:1433'
+});
+
+MATCH (stubAdmin:MSSQL_Base {objectid: 'S-1-5-21-TEST-CANON-A:LINKED'}),
+ (targetAdmin:MSSQL_Server {objectid: 'S-1-5-21-TEST-TGT-A:1433'}),
+ (stubLink:MSSQL_Base {objectid: 'S-1-5-21-TEST-CANON-B:LINKED'}),
+ (targetLink:MSSQL_Server {objectid: 'S-1-5-21-TEST-TGT-B:1433'})
+CREATE (stubAdmin)-[:MSSQL_LinkedAsAdmin]->(targetAdmin)
+CREATE (stubLink)-[:MSSQL_LinkedTo]->(targetLink);
diff --git a/tests/fixtures/dedupe_mssql_servers.cypher b/tests/fixtures/dedupe_mssql_servers.cypher
new file mode 100644
index 0000000..e77bde1
--- /dev/null
+++ b/tests/fixtures/dedupe_mssql_servers.cypher
@@ -0,0 +1,44 @@
+CREATE (a1:MSSQL_Server:MSSQL_Base:Base {
+ objectid: 'sccmdb:1433',
+ name: 'sccmdb.training.local:1433',
+ sqlServerName: 'SCCMDB',
+ version: '15.0.4322.2'
+});
+CREATE (a2:MSSQL_Server:MSSQL_Base:Base {
+ objectid: 'S-1-5-21-1105:1433',
+ name: 'SCCMDB.training.local:1433',
+ xpCmdShellEnabled: false
+});
+CREATE (a3:MSSQL_Server:MSSQL_Base:Base {
+ objectid: 'sccmdb.training.local:1433',
+ name: 'SCCMDB.training.local:1433',
+ extendedProtection: 'Off'
+});
+
+CREATE (b1:MSSQL_Server:MSSQL_Base:Base {
+ objectid: 'sql01\\app1:1444',
+ name: 'sql01.training.local:1444',
+ instanceName: 'APP1'
+});
+CREATE (b2:MSSQL_Server:MSSQL_Base:Base {
+ objectid: 'sql01\\app2:1455',
+ name: 'sql01.training.local:1455',
+ instanceName: 'APP2'
+});
+CREATE (b3:MSSQL_Server:MSSQL_Base:Base {
+ objectid: 'sql01\\sql01:1433',
+ name: 'sql01.training.local:1433',
+ instanceName: 'SQL01'
+});
+
+CREATE (role:MSSQL_ServerRole:MSSQL_Base:Base {
+ objectid: 'sysadmin@S-1-5-21-1105:1433',
+ name: 'sysadmin',
+ SQLServer: 'SCCMDB.training.local:1433'
+});
+
+MATCH (canonical:MSSQL_Server {objectid: 'sccmdb:1433'}),
+ (stale:MSSQL_Server {objectid: 'S-1-5-21-1105:1433'}),
+ (role:MSSQL_ServerRole {name: 'sysadmin'})
+CREATE (canonical)-[:MSSQL_Contains {traversable: true}]->(role)
+CREATE (stale)-[:MSSQL_Contains {traversable: true}]->(role);
diff --git a/tests/fixtures/mssql_priv_esc_group_owned_login.cypher b/tests/fixtures/mssql_priv_esc_group_owned_login.cypher
new file mode 100644
index 0000000..fed5c76
--- /dev/null
+++ b/tests/fixtures/mssql_priv_esc_group_owned_login.cypher
@@ -0,0 +1,48 @@
+// Six nested AD groups prove inherited SQL logins are traversed.
+
+CREATE (u:User {
+ name: 'NESTEDUSER@TEST.LOCAL',
+ objectid: 'S-1-5-21-TEST-3401',
+ enabled: true,
+ domain: 'TEST.LOCAL'
+});
+
+CREATE (g1:Group {name: 'G1@TEST.LOCAL', objectid: 'S-1-5-21-TEST-G1', domain: 'TEST.LOCAL'});
+CREATE (g2:Group {name: 'G2@TEST.LOCAL', objectid: 'S-1-5-21-TEST-G2', domain: 'TEST.LOCAL'});
+CREATE (g3:Group {name: 'G3@TEST.LOCAL', objectid: 'S-1-5-21-TEST-G3', domain: 'TEST.LOCAL'});
+CREATE (g4:Group {name: 'G4@TEST.LOCAL', objectid: 'S-1-5-21-TEST-G4', domain: 'TEST.LOCAL'});
+CREATE (g5:Group {name: 'G5@TEST.LOCAL', objectid: 'S-1-5-21-TEST-G5', domain: 'TEST.LOCAL'});
+CREATE (g6:Group {name: 'SQL OWNERS@TEST.LOCAL', objectid: 'S-1-5-21-TEST-G6', domain: 'TEST.LOCAL'});
+
+CREATE (login:MSSQL_Login {
+ name: 'TEST\\group-login',
+ SQLServer: 'sql01.test.local:1433'
+});
+
+CREATE (role:MSSQL_ServerRole {
+ name: 'sysadmin',
+ SQLServer: 'sql01.test.local:1433'
+});
+
+CREATE (server:MSSQL_Server {
+ name: 'sql01.test.local:1433',
+ xpCmdShellEnabled: false
+});
+
+MATCH (u:User {objectid: 'S-1-5-21-TEST-3401'}),
+ (g1:Group {objectid: 'S-1-5-21-TEST-G1'}),
+ (g2:Group {objectid: 'S-1-5-21-TEST-G2'}),
+ (g3:Group {objectid: 'S-1-5-21-TEST-G3'}),
+ (g4:Group {objectid: 'S-1-5-21-TEST-G4'}),
+ (g5:Group {objectid: 'S-1-5-21-TEST-G5'}),
+ (g6:Group {objectid: 'S-1-5-21-TEST-G6'}),
+ (login:MSSQL_Login {name: 'TEST\\group-login'}),
+ (role:MSSQL_ServerRole {name: 'sysadmin'})
+CREATE (u)-[:MemberOf]->(g1)
+CREATE (g1)-[:MemberOf]->(g2)
+CREATE (g2)-[:MemberOf]->(g3)
+CREATE (g3)-[:MemberOf]->(g4)
+CREATE (g4)-[:MemberOf]->(g5)
+CREATE (g5)-[:MemberOf]->(g6)
+CREATE (g6)-[:MSSQL_HasLogin]->(login)
+CREATE (login)-[:MSSQL_MemberOf]->(role);
diff --git a/tests/fixtures/mssql_priv_esc_linked_server_empty_stub.cypher b/tests/fixtures/mssql_priv_esc_linked_server_empty_stub.cypher
new file mode 100644
index 0000000..f1bc42f
--- /dev/null
+++ b/tests/fixtures/mssql_priv_esc_linked_server_empty_stub.cypher
@@ -0,0 +1,37 @@
+// Empty linked-server stub names must not match every MSSQL_Server.
+
+CREATE (srvA:MSSQL_Server {
+ name: 'empty-src.test.local:1433',
+ sqlServerName: 'empty-src\\SRCINST'
+});
+
+CREATE (stub:MSSQL_Base {name: 'LinkedServer:EMPTY-SRC\\SRCINST'});
+
+CREATE (srvBStub:MSSQL_Server {name: ''});
+
+CREATE (unrelated:MSSQL_Server {name: 'unrelated.test.local:1433'});
+CREATE (unrelatedRole:MSSQL_ServerRole {name: 'sysadmin', SQLServer: 'unrelated.test.local:1433'});
+
+CREATE (u:User {
+ name: 'EMPTYSTUB@TEST.LOCAL',
+ objectid: 'S-1-5-21-TEST-3702',
+ enabled: true,
+ domain: 'TEST.LOCAL'
+});
+
+CREATE (login:MSSQL_Login {
+ name: 'TEST\\emptystub',
+ SQLServer: 'empty-src.test.local:1433'
+});
+
+MATCH (u:User {objectid: 'S-1-5-21-TEST-3702'}),
+ (login:MSSQL_Login {name: 'TEST\\emptystub'}),
+ (srvA:MSSQL_Server {name: 'empty-src.test.local:1433'}),
+ (stub:MSSQL_Base {name: 'LinkedServer:EMPTY-SRC\\SRCINST'}),
+ (srvBStub:MSSQL_Server {name: ''}),
+ (unrelated:MSSQL_Server {name: 'unrelated.test.local:1433'}),
+ (unrelatedRole:MSSQL_ServerRole {SQLServer: 'unrelated.test.local:1433'})
+CREATE (u)-[:MSSQL_HasLogin]->(login)
+CREATE (login)-[:MSSQL_Connect]->(srvA)
+CREATE (stub)-[:MSSQL_LinkedAsAdmin]->(srvBStub)
+CREATE (unrelated)-[:MSSQL_Contains]->(unrelatedRole);
diff --git a/tests/fixtures/mssql_priv_esc_linked_server_name_overmatch.cypher b/tests/fixtures/mssql_priv_esc_linked_server_name_overmatch.cypher
new file mode 100644
index 0000000..0579feb
--- /dev/null
+++ b/tests/fixtures/mssql_priv_esc_linked_server_name_overmatch.cypher
@@ -0,0 +1,43 @@
+// Stub name sql01 must not match the sibling host sql01b.
+
+CREATE (srvA:MSSQL_Server {
+ name: 'lab-src.test.local:1433',
+ sqlServerName: 'lab-src\\SRCINST'
+});
+
+CREATE (stub:MSSQL_Base {name: 'LinkedServer:LAB-SRC\\SRCINST'});
+
+CREATE (srvBStub:MSSQL_Server {name: 'sql01'});
+
+CREATE (srvBIntended:MSSQL_Server {name: 'sql01.test.local:1433'});
+CREATE (intendedRole:MSSQL_ServerRole {name: 'sysadmin', SQLServer: 'sql01.test.local:1433'});
+
+CREATE (srvBDecoy:MSSQL_Server {name: 'sql01b.test.local:1433'});
+CREATE (decoyRole:MSSQL_ServerRole {name: 'sysadmin', SQLServer: 'sql01b.test.local:1433'});
+
+CREATE (u:User {
+ name: 'NAMEOVERMATCH@TEST.LOCAL',
+ objectid: 'S-1-5-21-TEST-3701',
+ enabled: true,
+ domain: 'TEST.LOCAL'
+});
+
+CREATE (login:MSSQL_Login {
+ name: 'TEST\\nameovermatch',
+ SQLServer: 'lab-src.test.local:1433'
+});
+
+MATCH (u:User {objectid: 'S-1-5-21-TEST-3701'}),
+ (login:MSSQL_Login {name: 'TEST\\nameovermatch'}),
+ (srvA:MSSQL_Server {name: 'lab-src.test.local:1433'}),
+ (stub:MSSQL_Base {name: 'LinkedServer:LAB-SRC\\SRCINST'}),
+ (srvBStub:MSSQL_Server {name: 'sql01'}),
+ (srvBIntended:MSSQL_Server {name: 'sql01.test.local:1433'}),
+ (intendedRole:MSSQL_ServerRole {SQLServer: 'sql01.test.local:1433'}),
+ (srvBDecoy:MSSQL_Server {name: 'sql01b.test.local:1433'}),
+ (decoyRole:MSSQL_ServerRole {SQLServer: 'sql01b.test.local:1433'})
+CREATE (u)-[:MSSQL_HasLogin]->(login)
+CREATE (login)-[:MSSQL_Connect]->(srvA)
+CREATE (stub)-[:MSSQL_LinkedAsAdmin]->(srvBStub)
+CREATE (srvBIntended)-[:MSSQL_Contains]->(intendedRole)
+CREATE (srvBDecoy)-[:MSSQL_Contains]->(decoyRole);
diff --git a/tests/fixtures/mssql_priv_esc_linked_server_overmatch.cypher b/tests/fixtures/mssql_priv_esc_linked_server_overmatch.cypher
new file mode 100644
index 0000000..5364c33
--- /dev/null
+++ b/tests/fixtures/mssql_priv_esc_linked_server_overmatch.cypher
@@ -0,0 +1,43 @@
+// SQL01-BAK must not bridge through a substring match on SQL01.
+
+CREATE (srvA:MSSQL_Server {
+ name: 'lab-sql01.test.local:1433',
+ sqlServerName: 'lab-sql01\\SQL01',
+ objectid: 'S-1-5-21-TEST-4806:1433'
+});
+
+CREATE (stub:MSSQL_Base {
+ name: 'LinkedServer:LAB-SQL01\\SQL01-BAK'
+});
+
+CREATE (srvB:MSSQL_Server {
+ name: 'sccmdb.test.local:1433'
+});
+
+CREATE (role:MSSQL_ServerRole {
+ name: 'sysadmin',
+ SQLServer: 'sccmdb.test.local:1433'
+});
+
+CREATE (u:User {
+ name: 'OVERMATCH@TEST.LOCAL',
+ objectid: 'S-1-5-21-TEST-3501',
+ enabled: true,
+ domain: 'TEST.LOCAL'
+});
+
+CREATE (login:MSSQL_Login {
+ name: 'TEST\\overmatch',
+ SQLServer: 'lab-sql01.test.local:1433'
+});
+
+MATCH (u:User {objectid: 'S-1-5-21-TEST-3501'}),
+ (login:MSSQL_Login {name: 'TEST\\overmatch'}),
+ (srvA:MSSQL_Server {name: 'lab-sql01.test.local:1433'}),
+ (srvB:MSSQL_Server {name: 'sccmdb.test.local:1433'}),
+ (stub:MSSQL_Base {name: 'LinkedServer:LAB-SQL01\\SQL01-BAK'}),
+ (role:MSSQL_ServerRole {name: 'sysadmin'})
+CREATE (u)-[:MSSQL_HasLogin]->(login)
+CREATE (login)-[:MSSQL_Connect]->(srvA)
+CREATE (stub)-[:MSSQL_LinkedAsAdmin]->(srvB)
+CREATE (srvB)-[:MSSQL_Contains]->(role);
diff --git a/tests/fixtures/mssql_priv_esc_linked_server_shared_host_sid.cypher b/tests/fixtures/mssql_priv_esc_linked_server_shared_host_sid.cypher
new file mode 100644
index 0000000..d921671
--- /dev/null
+++ b/tests/fixtures/mssql_priv_esc_linked_server_shared_host_sid.cypher
@@ -0,0 +1,45 @@
+// Ambiguous host SID prefixes shared by SQL instances must not bridge.
+
+CREATE (srvA1:MSSQL_Server {
+ name: 'shared-host.test.local:1433',
+ sqlServerName: 'shared-host\\INST1',
+ objectid: 'S-1-5-21-TEST-SHARED:1433'
+});
+CREATE (srvA2:MSSQL_Server {
+ name: 'shared-host.test.local:1434',
+ sqlServerName: 'shared-host\\INST2',
+ objectid: 'S-1-5-21-TEST-SHARED:1434'
+});
+
+CREATE (stub:MSSQL_Base {
+ name: 'S-1-5-21-TEST-SHARED:INST2',
+ objectid: 'S-1-5-21-TEST-SHARED:INST2'
+});
+
+CREATE (srvBStub:MSSQL_Server {name: 'sccmdb.test.local'});
+CREATE (srvB:MSSQL_Server {name: 'sccmdb.test.local:1433'});
+CREATE (role:MSSQL_ServerRole {name: 'sysadmin', SQLServer: 'sccmdb.test.local:1433'});
+
+CREATE (u:User {
+ name: 'SHAREDSID@TEST.LOCAL',
+ objectid: 'S-1-5-21-TEST-3703',
+ enabled: true,
+ domain: 'TEST.LOCAL'
+});
+
+CREATE (login:MSSQL_Login {
+ name: 'TEST\\sharedsid',
+ SQLServer: 'shared-host.test.local:1433'
+});
+
+MATCH (u:User {objectid: 'S-1-5-21-TEST-3703'}),
+ (login:MSSQL_Login {name: 'TEST\\sharedsid'}),
+ (srvA1:MSSQL_Server {objectid: 'S-1-5-21-TEST-SHARED:1433'}),
+ (srvB:MSSQL_Server {name: 'sccmdb.test.local:1433'}),
+ (srvBStub:MSSQL_Server {name: 'sccmdb.test.local'}),
+ (stub:MSSQL_Base {objectid: 'S-1-5-21-TEST-SHARED:INST2'}),
+ (role:MSSQL_ServerRole {SQLServer: 'sccmdb.test.local:1433'})
+CREATE (u)-[:MSSQL_HasLogin]->(login)
+CREATE (login)-[:MSSQL_Connect]->(srvA1)
+CREATE (stub)-[:MSSQL_LinkedAsAdmin]->(srvBStub)
+CREATE (srvB)-[:MSSQL_Contains]->(role);
diff --git a/tests/fixtures/mssql_priv_esc_linked_server_sid.cypher b/tests/fixtures/mssql_priv_esc_linked_server_sid.cypher
new file mode 100644
index 0000000..0d51cba
--- /dev/null
+++ b/tests/fixtures/mssql_priv_esc_linked_server_sid.cypher
@@ -0,0 +1,49 @@
+// SID-named stubs fall back to host-SID prefix matching.
+
+CREATE (srvA:MSSQL_Server {
+ name: 'lab-sql01.test.local:1433',
+ sqlServerName: 'lab-sql01\\SQL01',
+ objectid: 'S-1-5-21-TEST-4806:1433'
+});
+
+CREATE (stub:MSSQL_Base {
+ name: 'S-1-5-21-TEST-4806:SQL01',
+ objectid: 'S-1-5-21-TEST-4806:SQL01'
+});
+
+CREATE (srvBStub:MSSQL_Server {
+ name: 'sccmdb.test.local'
+});
+
+CREATE (srvB:MSSQL_Server {
+ name: 'sccmdb.test.local:1433'
+});
+
+CREATE (role:MSSQL_ServerRole {
+ name: 'sysadmin',
+ SQLServer: 'sccmdb.test.local:1433'
+});
+
+CREATE (u:User {
+ name: 'SIDLINK@TEST.LOCAL',
+ objectid: 'S-1-5-21-TEST-3301',
+ enabled: true,
+ domain: 'TEST.LOCAL'
+});
+
+CREATE (login:MSSQL_Login {
+ name: 'TEST\\sidlink',
+ SQLServer: 'lab-sql01.test.local:1433'
+});
+
+MATCH (u:User {objectid: 'S-1-5-21-TEST-3301'}),
+ (login:MSSQL_Login {name: 'TEST\\sidlink'}),
+ (srvA:MSSQL_Server {name: 'lab-sql01.test.local:1433'}),
+ (srvB:MSSQL_Server {name: 'sccmdb.test.local:1433'}),
+ (srvBStub:MSSQL_Server {name: 'sccmdb.test.local'}),
+ (stub:MSSQL_Base {name: 'S-1-5-21-TEST-4806:SQL01'}),
+ (role:MSSQL_ServerRole {name: 'sysadmin'})
+CREATE (u)-[:MSSQL_HasLogin]->(login)
+CREATE (login)-[:MSSQL_Connect]->(srvA)
+CREATE (stub)-[:MSSQL_LinkedAsAdmin]->(srvBStub)
+CREATE (srvB)-[:MSSQL_Contains]->(role);
diff --git a/tests/fixtures/mssql_priv_esc_owns_server_role.cypher b/tests/fixtures/mssql_priv_esc_owns_server_role.cypher
new file mode 100644
index 0000000..f2b89ff
--- /dev/null
+++ b/tests/fixtures/mssql_priv_esc_owns_server_role.cypher
@@ -0,0 +1,33 @@
+// Owning sysadmin is enough to add yourself to the role.
+
+CREATE (srv:MSSQL_Server {
+ name: 'sql01.test.local:1433',
+ sqlServerName: 'sql01',
+ objectid: 'S-1-5-21-TEST-4001:1433'
+});
+
+CREATE (role:MSSQL_ServerRole {
+ name: 'sysadmin',
+ SQLServer: 'sql01.test.local:1433'
+});
+
+CREATE (u:User {
+ name: 'OWNSROLE@TEST.LOCAL',
+ objectid: 'S-1-5-21-TEST-3601',
+ enabled: true,
+ domain: 'TEST.LOCAL'
+});
+
+CREATE (login:MSSQL_Login {
+ name: 'TEST\\ownsrole',
+ SQLServer: 'sql01.test.local:1433'
+});
+
+MATCH (u:User {objectid: 'S-1-5-21-TEST-3601'}),
+ (login:MSSQL_Login {name: 'TEST\\ownsrole'}),
+ (srv:MSSQL_Server {name: 'sql01.test.local:1433'}),
+ (role:MSSQL_ServerRole {name: 'sysadmin'})
+CREATE (u)-[:MSSQL_HasLogin]->(login)
+CREATE (login)-[:MSSQL_Connect]->(srv)
+CREATE (login)-[:MSSQL_Owns]->(role)
+CREATE (srv)-[:MSSQL_Contains]->(role);
diff --git a/tests/fixtures/mssql_privilege_escalation_linked_server.cypher b/tests/fixtures/mssql_privilege_escalation_linked_server.cypher
new file mode 100644
index 0000000..5c034a6
--- /dev/null
+++ b/tests/fixtures/mssql_privilege_escalation_linked_server.cypher
@@ -0,0 +1,90 @@
+// Case-mismatched linked-server stubs still bridge to the source server.
+
+CREATE (srvA:MSSQL_Server {
+ name: 'lab-sql01.test.local:1433',
+ sqlServerName: 'lab-sql01\\SQL01'
+});
+
+CREATE (stub:MSSQL_Base {
+ name: 'LinkedServer:LAB-SQL01\\SQL01'
+});
+
+CREATE (srvB:MSSQL_Server {
+ name: 'sccmdb.test.local:1433'
+});
+
+CREATE (role:MSSQL_ServerRole {
+ name: 'sysadmin',
+ SQLServer: 'sccmdb.test.local:1433'
+});
+
+CREATE (u:User {
+ name: 'LOWPRIV@TEST.LOCAL',
+ objectid: 'S-1-5-21-TEST-3201',
+ enabled: true,
+ domain: 'TEST.LOCAL'
+});
+
+CREATE (groupUser:User {
+ name: 'GROUPLINK@TEST.LOCAL',
+ objectid: 'S-1-5-21-TEST-3202',
+ enabled: true,
+ domain: 'TEST.LOCAL'
+});
+
+CREATE (broadUser:User {
+ name: 'BROADLINK@TEST.LOCAL',
+ objectid: 'S-1-5-21-TEST-3203',
+ enabled: true,
+ domain: 'TEST.LOCAL'
+});
+
+CREATE (linkedGroup:Group {
+ name: 'LINKED SQL USERS@TEST.LOCAL',
+ objectid: 'S-1-5-21-TEST-LINKED-GROUP',
+ domain: 'TEST.LOCAL'
+});
+
+CREATE (domainUsers:Group {
+ name: 'DOMAIN USERS@TEST.LOCAL',
+ objectid: 'S-1-5-21-TEST-513',
+ domain: 'TEST.LOCAL'
+});
+
+CREATE (login:MSSQL_Login {
+ name: 'TEST\\lowpriv',
+ SQLServer: 'lab-sql01.test.local:1433'
+});
+
+CREATE (groupLogin:MSSQL_Login {
+ name: 'TEST\\linked-group',
+ SQLServer: 'lab-sql01.test.local:1433'
+});
+
+CREATE (broadLogin:MSSQL_Login {
+ name: 'TEST\\Domain Users',
+ SQLServer: 'lab-sql01.test.local:1433'
+});
+
+MATCH (u:User {objectid: 'S-1-5-21-TEST-3201'}),
+ (groupUser:User {objectid: 'S-1-5-21-TEST-3202'}),
+ (broadUser:User {objectid: 'S-1-5-21-TEST-3203'}),
+ (linkedGroup:Group {objectid: 'S-1-5-21-TEST-LINKED-GROUP'}),
+ (domainUsers:Group {objectid: 'S-1-5-21-TEST-513'}),
+ (login:MSSQL_Login {name: 'TEST\\lowpriv'}),
+ (groupLogin:MSSQL_Login {name: 'TEST\\linked-group'}),
+ (broadLogin:MSSQL_Login {name: 'TEST\\Domain Users'}),
+ (srvA:MSSQL_Server {name: 'lab-sql01.test.local:1433'}),
+ (srvB:MSSQL_Server {name: 'sccmdb.test.local:1433'}),
+ (stub:MSSQL_Base {name: 'LinkedServer:LAB-SQL01\\SQL01'}),
+ (role:MSSQL_ServerRole {name: 'sysadmin'})
+CREATE (u)-[:MSSQL_HasLogin]->(login)
+CREATE (login)-[:MSSQL_Connect]->(srvA)
+CREATE (groupUser)-[:MemberOf]->(linkedGroup)
+CREATE (linkedGroup)-[:MSSQL_HasLogin]->(groupLogin)
+CREATE (groupLogin)-[:MSSQL_Connect]->(srvA)
+CREATE (broadUser)-[:MemberOf]->(domainUsers)
+CREATE (domainUsers)-[:MSSQL_HasLogin]->(broadLogin)
+CREATE (broadLogin)-[:MSSQL_Connect]->(srvA)
+CREATE (stub)-[:MSSQL_LinkedAsAdmin]->(srvB)
+CREATE (srvB)-[:MSSQL_Contains]->(role);
diff --git a/tests/fixtures/mssql_sccm.cypher b/tests/fixtures/mssql_sccm.cypher
deleted file mode 100644
index 54ae2a3..0000000
--- a/tests/fixtures/mssql_sccm.cypher
+++ /dev/null
@@ -1,40 +0,0 @@
-// MSSQL_SCCM hybrid positive: a domain user has an MSSQL_Login that controls
-// the SCCM site database, which in turn -[:SCCM_AssignAllPermissions]-> the
-// SCCM_Site. Cross-schema path with traversable=true on every edge.
-
-CREATE (site:SCCM_Site:SCCM_Base:Base {siteCode: 'P01', sourceForest: 'TEST.LOCAL'});
-
-CREATE (server:MSSQL_Server:MSSQL_Base:Base {
- name: 'sql01.test.local:1433',
- xpCmdShellEnabled: false,
- extendedProtection: false
-});
-
-CREATE (login:MSSQL_Login:MSSQL_Base:Base {
- name: 'TEST\\sccmadmin',
- SQLServer: 'sql01.test.local:1433',
- type: 'WINDOWS_LOGIN'
-});
-
-CREATE (sccmDb:MSSQL_Database:MSSQL_Base:Base {
- name: 'CM_P01',
- SQLServer: 'sql01.test.local:1433',
- isTrustworthy: true
-});
-
-CREATE (u:User:Base {
- name: 'sccmadmin@TEST.LOCAL',
- objectid: 'S-1-5-21-TEST-1300',
- enabled: true,
- domain: 'TEST.LOCAL'
-});
-
-// shortestPath() requires every edge in the chain to carry traversable=true,
-// regardless of which schema the edge belongs to (mssql_sccm.py:82).
-MATCH (u:User {objectid: 'S-1-5-21-TEST-1300'}),
- (login:MSSQL_Login {name: 'TEST\\sccmadmin'}),
- (sccmDb:MSSQL_Database {name: 'CM_P01'}),
- (site:SCCM_Site {siteCode: 'P01'})
-CREATE (u)-[:MSSQL_HasLogin {traversable: true}]->(login)
-CREATE (login)-[:MSSQL_ControlDB {traversable: true}]->(sccmDb)
-CREATE (sccmDb)-[:SCCM_AssignAllPermissions {traversable: true}]->(site);
diff --git a/tests/fixtures/sccm_priv_esc_linked_server_empty_stub.cypher b/tests/fixtures/sccm_priv_esc_linked_server_empty_stub.cypher
new file mode 100644
index 0000000..344cf09
--- /dev/null
+++ b/tests/fixtures/sccm_priv_esc_linked_server_empty_stub.cypher
@@ -0,0 +1,38 @@
+// Empty SCCM linked-server stub names must not match site database servers.
+
+CREATE (site:SCCM_Site:SCCM_Base:Base {
+ siteCode: 'P11',
+ displayName: 'Primary Site',
+ objectid: 'P11',
+ sourceForest: 'TEST.LOCAL'
+});
+
+CREATE (srvA:MSSQL_Server:MSSQL_Base:Base {
+ name: 'empty-src.test.local:1433',
+ sqlServerName: 'empty-src\\SRCINST'
+});
+CREATE (stub:MSSQL_Base {name: 'LinkedServer:EMPTY-SRC\\SRCINST'});
+CREATE (srvBStub:MSSQL_Server:MSSQL_Base:Base {name: ''});
+
+CREATE (unrelated:MSSQL_Server:MSSQL_Base:Base {name: 'unrelated-cm.test.local:1433'});
+CREATE (unrelatedRole:MSSQL_ServerRole:MSSQL_Base:Base {name: 'sysadmin', SQLServer: 'unrelated-cm.test.local:1433'});
+CREATE (unrelatedDb:MSSQL_Database:MSSQL_Base:Base {name: 'CM_P11', SQLServer: 'unrelated-cm.test.local:1433'});
+
+CREATE (u:User:Base {name: 'SCCMEMPTYSTUB@TEST.LOCAL', objectid: 'S-1-5-21-TEST-311', enabled: true, domain: 'TEST.LOCAL'});
+CREATE (login:MSSQL_Login:MSSQL_Base:Base {name: 'TEST\\sccmemptystub', SQLServer: 'empty-src.test.local:1433'});
+
+MATCH (site:SCCM_Site {siteCode: 'P11'}),
+ (u:User {objectid: 'S-1-5-21-TEST-311'}),
+ (login:MSSQL_Login {name: 'TEST\\sccmemptystub'}),
+ (srvA:MSSQL_Server {name: 'empty-src.test.local:1433'}),
+ (stub:MSSQL_Base {name: 'LinkedServer:EMPTY-SRC\\SRCINST'}),
+ (srvBStub:MSSQL_Server {name: ''}),
+ (unrelated:MSSQL_Server {name: 'unrelated-cm.test.local:1433'}),
+ (unrelatedRole:MSSQL_ServerRole {SQLServer: 'unrelated-cm.test.local:1433'}),
+ (unrelatedDb:MSSQL_Database {name: 'CM_P11'})
+CREATE (u)-[:MSSQL_HasLogin]->(login)
+CREATE (login)-[:MSSQL_Connect]->(srvA)
+CREATE (stub)-[:MSSQL_LinkedAsAdmin]->(srvBStub)
+CREATE (unrelated)-[:MSSQL_Contains]->(unrelatedRole)
+CREATE (unrelated)-[:MSSQL_Contains]->(unrelatedDb)
+CREATE (unrelatedDb)-[:SCCM_AssignAllPermissions]->(site);
diff --git a/tests/fixtures/sccm_priv_esc_linked_server_name_overmatch.cypher b/tests/fixtures/sccm_priv_esc_linked_server_name_overmatch.cypher
new file mode 100644
index 0000000..5787407
--- /dev/null
+++ b/tests/fixtures/sccm_priv_esc_linked_server_name_overmatch.cypher
@@ -0,0 +1,48 @@
+// Stub name cmsql must not match the sibling host cmsql-dr.
+
+CREATE (site:SCCM_Site:SCCM_Base:Base {
+ siteCode: 'P10',
+ displayName: 'Primary Site',
+ objectid: 'P10',
+ sourceForest: 'TEST.LOCAL'
+});
+
+CREATE (srvA:MSSQL_Server:MSSQL_Base:Base {
+ name: 'lab-src.test.local:1433',
+ sqlServerName: 'lab-src\\SRCINST'
+});
+CREATE (stub:MSSQL_Base {name: 'LinkedServer:LAB-SRC\\SRCINST'});
+CREATE (srvBStub:MSSQL_Server:MSSQL_Base:Base {name: 'cmsql'});
+
+CREATE (srvBIntended:MSSQL_Server:MSSQL_Base:Base {name: 'cmsql.test.local:1433'});
+CREATE (intendedRole:MSSQL_ServerRole:MSSQL_Base:Base {name: 'sysadmin', SQLServer: 'cmsql.test.local:1433'});
+CREATE (intendedDb:MSSQL_Database:MSSQL_Base:Base {name: 'CM_P10', SQLServer: 'cmsql.test.local:1433'});
+
+CREATE (srvBDecoy:MSSQL_Server:MSSQL_Base:Base {name: 'cmsql-dr.test.local:1433'});
+CREATE (decoyRole:MSSQL_ServerRole:MSSQL_Base:Base {name: 'sysadmin', SQLServer: 'cmsql-dr.test.local:1433'});
+CREATE (decoyDb:MSSQL_Database:MSSQL_Base:Base {name: 'CM_DR', SQLServer: 'cmsql-dr.test.local:1433'});
+
+CREATE (u:User:Base {name: 'SCCMNAMEOVERMATCH@TEST.LOCAL', objectid: 'S-1-5-21-TEST-310', enabled: true, domain: 'TEST.LOCAL'});
+CREATE (login:MSSQL_Login:MSSQL_Base:Base {name: 'TEST\\sccmnameovermatch', SQLServer: 'lab-src.test.local:1433'});
+
+MATCH (site:SCCM_Site {siteCode: 'P10'}),
+ (u:User {objectid: 'S-1-5-21-TEST-310'}),
+ (login:MSSQL_Login {name: 'TEST\\sccmnameovermatch'}),
+ (srvA:MSSQL_Server {name: 'lab-src.test.local:1433'}),
+ (stub:MSSQL_Base {name: 'LinkedServer:LAB-SRC\\SRCINST'}),
+ (srvBStub:MSSQL_Server {name: 'cmsql'}),
+ (srvBIntended:MSSQL_Server {name: 'cmsql.test.local:1433'}),
+ (intendedRole:MSSQL_ServerRole {SQLServer: 'cmsql.test.local:1433'}),
+ (intendedDb:MSSQL_Database {name: 'CM_P10'}),
+ (srvBDecoy:MSSQL_Server {name: 'cmsql-dr.test.local:1433'}),
+ (decoyRole:MSSQL_ServerRole {SQLServer: 'cmsql-dr.test.local:1433'}),
+ (decoyDb:MSSQL_Database {name: 'CM_DR'})
+CREATE (u)-[:MSSQL_HasLogin]->(login)
+CREATE (login)-[:MSSQL_Connect]->(srvA)
+CREATE (stub)-[:MSSQL_LinkedAsAdmin]->(srvBStub)
+CREATE (srvBIntended)-[:MSSQL_Contains]->(intendedRole)
+CREATE (srvBIntended)-[:MSSQL_Contains]->(intendedDb)
+CREATE (intendedDb)-[:SCCM_AssignAllPermissions]->(site)
+CREATE (srvBDecoy)-[:MSSQL_Contains]->(decoyRole)
+CREATE (srvBDecoy)-[:MSSQL_Contains]->(decoyDb)
+CREATE (decoyDb)-[:SCCM_AssignAllPermissions]->(site);
diff --git a/tests/fixtures/sccm_priv_esc_linked_server_shared_host_sid.cypher b/tests/fixtures/sccm_priv_esc_linked_server_shared_host_sid.cypher
new file mode 100644
index 0000000..4bcc810
--- /dev/null
+++ b/tests/fixtures/sccm_priv_esc_linked_server_shared_host_sid.cypher
@@ -0,0 +1,45 @@
+// SCCM linked paths must not bridge ambiguous shared host SID prefixes.
+
+CREATE (site:SCCM_Site:SCCM_Base:Base {
+ siteCode: 'P12',
+ displayName: 'Primary Site',
+ objectid: 'P12',
+ sourceForest: 'TEST.LOCAL'
+});
+
+CREATE (srvA1:MSSQL_Server:MSSQL_Base:Base {
+ name: 'shared-host.test.local:1433',
+ sqlServerName: 'shared-host\\INST1',
+ objectid: 'S-1-5-21-TEST-SHARED:1433'
+});
+CREATE (srvA2:MSSQL_Server:MSSQL_Base:Base {
+ name: 'shared-host.test.local:1434',
+ sqlServerName: 'shared-host\\INST2',
+ objectid: 'S-1-5-21-TEST-SHARED:1434'
+});
+
+CREATE (stub:MSSQL_Base {name: 'S-1-5-21-TEST-SHARED:INST2', objectid: 'S-1-5-21-TEST-SHARED:INST2'});
+
+CREATE (srvBStub:MSSQL_Server:MSSQL_Base:Base {name: 'cmsql.test.local'});
+CREATE (srvB:MSSQL_Server:MSSQL_Base:Base {name: 'cmsql.test.local:1433'});
+CREATE (role:MSSQL_ServerRole:MSSQL_Base:Base {name: 'sysadmin', SQLServer: 'cmsql.test.local:1433'});
+CREATE (sccmDb:MSSQL_Database:MSSQL_Base:Base {name: 'CM_P12', SQLServer: 'cmsql.test.local:1433'});
+
+CREATE (u:User:Base {name: 'SCCMSHAREDSID@TEST.LOCAL', objectid: 'S-1-5-21-TEST-312', enabled: true, domain: 'TEST.LOCAL'});
+CREATE (login:MSSQL_Login:MSSQL_Base:Base {name: 'TEST\\sccmsharedsid', SQLServer: 'shared-host.test.local:1433'});
+
+MATCH (site:SCCM_Site {siteCode: 'P12'}),
+ (u:User {objectid: 'S-1-5-21-TEST-312'}),
+ (login:MSSQL_Login {name: 'TEST\\sccmsharedsid'}),
+ (srvA1:MSSQL_Server {objectid: 'S-1-5-21-TEST-SHARED:1433'}),
+ (stub:MSSQL_Base {objectid: 'S-1-5-21-TEST-SHARED:INST2'}),
+ (srvBStub:MSSQL_Server {name: 'cmsql.test.local'}),
+ (srvB:MSSQL_Server {name: 'cmsql.test.local:1433'}),
+ (role:MSSQL_ServerRole {SQLServer: 'cmsql.test.local:1433'}),
+ (sccmDb:MSSQL_Database {name: 'CM_P12'})
+CREATE (u)-[:MSSQL_HasLogin]->(login)
+CREATE (login)-[:MSSQL_Connect]->(srvA1)
+CREATE (stub)-[:MSSQL_LinkedAsAdmin]->(srvBStub)
+CREATE (srvB)-[:MSSQL_Contains]->(role)
+CREATE (srvB)-[:MSSQL_Contains]->(sccmDb)
+CREATE (sccmDb)-[:SCCM_AssignAllPermissions]->(site);
diff --git a/tests/fixtures/sccm_privilege_escalation.cypher b/tests/fixtures/sccm_privilege_escalation.cypher
new file mode 100644
index 0000000..e8afc00
--- /dev/null
+++ b/tests/fixtures/sccm_privilege_escalation.cypher
@@ -0,0 +1,336 @@
+// MSSQL edges omit traversable to match MSSQLHound output.
+
+CREATE (site:SCCM_Site:SCCM_Base:Base {
+ siteCode: 'P01',
+ displayName: 'Primary Site',
+ objectid: 'P01',
+ sourceForest: 'TEST.LOCAL'
+});
+
+CREATE (otherSite:SCCM_Site:SCCM_Base:Base {
+ siteCode: 'X01',
+ displayName: 'Other Forest Site',
+ objectid: 'X01',
+ sourceForest: 'OTHER.LOCAL'
+});
+
+CREATE (sccmServer:MSSQL_Server:MSSQL_Base:Base {
+ name: 'sql01.test.local:1433',
+ xpCmdShellEnabled: false,
+ objectid: 'S-1-5-21-TEST-SQL01:1433'
+});
+
+CREATE (sccmDb:MSSQL_Database:MSSQL_Base:Base {
+ name: 'CM_P01',
+ SQLServer: 'sql01.test.local:1433'
+});
+
+CREATE (sysadmin:MSSQL_ServerRole:MSSQL_Base:Base {
+ name: 'sysadmin',
+ SQLServer: 'sql01.test.local:1433'
+});
+
+CREATE (dbOwner:MSSQL_DatabaseRole:MSSQL_Base:Base {
+ name: 'db_owner@CM_P01',
+ SQLServer: 'sql01.test.local:1433'
+});
+
+CREATE (fullRole:SCCM_SecurityRole:SCCM_Base:Base {
+ name: 'Full Administrator'
+});
+
+CREATE (adminUser:SCCM_AdminUser:SCCM_Base:Base {
+ name: 'TEST\\SCCM Admins',
+ objectid: 'TEST\\SCCM Admins@P01',
+ sourceSiteCode: 'P01',
+ isGroup: true
+});
+
+CREATE (roleOnlyAdmin:SCCM_AdminUser:SCCM_Base:Base {
+ name: 'TEST\\Role Admin',
+ objectid: 'TEST\\Role Admin@P01',
+ sourceSiteCode: 'P01'
+});
+
+CREATE (lowAdmin:SCCM_AdminUser:SCCM_Base:Base {
+ name: 'TEST\\Low SCCM Group',
+ objectid: 'TEST\\Low SCCM Group@P01',
+ sourceSiteCode: 'P01',
+ isGroup: true
+});
+
+CREATE (otherAdmin:SCCM_AdminUser:SCCM_Base:Base {
+ name: 'OTHER\\SCCM Admins',
+ objectid: 'OTHER\\SCCM Admins@X01',
+ sourceSiteCode: 'X01'
+});
+
+CREATE (sccmGroup:Group:Base {
+ name: 'SCCM ADMINS@TEST.LOCAL',
+ objectid: 'S-1-5-21-TEST-SCCM-GROUP',
+ domain: 'TEST.LOCAL'
+});
+
+CREATE (linkedSqlGroup:Group:Base {
+ name: 'LINKED SQL USERS@TEST.LOCAL',
+ objectid: 'S-1-5-21-TEST-LINKED-SQL-GROUP',
+ domain: 'TEST.LOCAL'
+});
+
+CREATE (domainUsers:Group:Base {
+ name: 'DOMAIN USERS@TEST.LOCAL',
+ objectid: 'S-1-5-21-TEST-513',
+ domain: 'TEST.LOCAL'
+});
+
+CREATE (lowGroup:Group:Base {
+ name: 'LOW SCCM GROUP@TEST.LOCAL',
+ objectid: 'S-1-5-21-TEST-LOW-GROUP',
+ domain: 'TEST.LOCAL'
+});
+
+CREATE (otherGroup:Group:Base {
+ name: 'OTHER SCCM GROUP@TEST.LOCAL',
+ objectid: 'S-1-5-21-TEST-OTHER-GROUP',
+ domain: 'TEST.LOCAL'
+});
+
+CREATE (pureUser:User:Base {name: 'PURE@TEST.LOCAL', objectid: 'S-1-5-21-TEST-100', enabled: true, domain: 'TEST.LOCAL'});
+CREATE (nestedUser:User:Base {name: 'NESTED@TEST.LOCAL', objectid: 'S-1-5-21-TEST-101', enabled: true, domain: 'TEST.LOCAL'});
+CREATE (sqlUser:User:Base {name: 'SQLSYS@TEST.LOCAL', objectid: 'S-1-5-21-TEST-102', enabled: true, domain: 'TEST.LOCAL'});
+CREATE (dbUserPrincipal:User:Base {name: 'SQLDB@TEST.LOCAL', objectid: 'S-1-5-21-TEST-103', enabled: true, domain: 'TEST.LOCAL'});
+CREATE (linkedUser:User:Base {name: 'LINKED@TEST.LOCAL', objectid: 'S-1-5-21-TEST-104', enabled: true, domain: 'TEST.LOCAL'});
+CREATE (alterUser:User:Base {name: 'ALTER@TEST.LOCAL', objectid: 'S-1-5-21-TEST-105', enabled: true, domain: 'TEST.LOCAL'});
+CREATE (roleOnlyUser:User:Base {name: 'ROLEONLY@TEST.LOCAL', objectid: 'S-1-5-21-TEST-106', enabled: true, domain: 'TEST.LOCAL'});
+CREATE (groupLinkedUser:User:Base {name: 'GROUPLINKED@TEST.LOCAL', objectid: 'S-1-5-21-TEST-107', enabled: true, domain: 'TEST.LOCAL'});
+CREATE (broadLinkedUser:User:Base {name: 'BROADLINKED@TEST.LOCAL', objectid: 'S-1-5-21-TEST-108', enabled: true, domain: 'TEST.LOCAL'});
+CREATE (execHostUser:User:Base {name: 'EXECCHAIN@TEST.LOCAL', objectid: 'S-1-5-21-TEST-109', enabled: true, domain: 'TEST.LOCAL'});
+CREATE (sqlServiceAccount:User:Base:Tag_Tier_Zero {name: 'SQLSVC@TEST.LOCAL', objectid: 'S-1-5-21-TEST-SQLSVC', enabled: true, domain: 'TEST.LOCAL'});
+CREATE (connectOnlyUser:User:Base {name: 'CONNECTONLY@TEST.LOCAL', objectid: 'S-1-5-21-TEST-200', enabled: true, domain: 'TEST.LOCAL'});
+CREATE (lowSccmUser:User:Base {name: 'LOWSCCM@TEST.LOCAL', objectid: 'S-1-5-21-TEST-201', enabled: true, domain: 'TEST.LOCAL'});
+CREATE (crossForestUser:User:Base {name: 'CROSSFOREST@TEST.LOCAL', objectid: 'S-1-5-21-TEST-202', enabled: true, domain: 'TEST.LOCAL'});
+CREATE (disabledExecUser:User:Base {name: 'DISABLEDEXEC@TEST.LOCAL', objectid: 'S-1-5-21-TEST-203', enabled: true, domain: 'TEST.LOCAL'});
+CREATE (noAdminLinkUser:User:Base {name: 'NOADMINLINK@TEST.LOCAL', objectid: 'S-1-5-21-TEST-204', enabled: true, domain: 'TEST.LOCAL'});
+
+CREATE (n1:Group:Base {name: 'N1@TEST.LOCAL', objectid: 'S-1-5-21-TEST-N1', domain: 'TEST.LOCAL'});
+CREATE (n2:Group:Base {name: 'N2@TEST.LOCAL', objectid: 'S-1-5-21-TEST-N2', domain: 'TEST.LOCAL'});
+CREATE (n3:Group:Base {name: 'N3@TEST.LOCAL', objectid: 'S-1-5-21-TEST-N3', domain: 'TEST.LOCAL'});
+CREATE (n4:Group:Base {name: 'N4@TEST.LOCAL', objectid: 'S-1-5-21-TEST-N4', domain: 'TEST.LOCAL'});
+CREATE (n5:Group:Base {name: 'N5@TEST.LOCAL', objectid: 'S-1-5-21-TEST-N5', domain: 'TEST.LOCAL'});
+CREATE (n6:Group:Base {name: 'N6@TEST.LOCAL', objectid: 'S-1-5-21-TEST-N6', domain: 'TEST.LOCAL'});
+
+CREATE (sqlLogin:MSSQL_Login:MSSQL_Base:Base {name: 'TEST\\sqlsys', SQLServer: 'sql01.test.local:1433'});
+CREATE (dbLogin:MSSQL_Login:MSSQL_Base:Base {name: 'TEST\\sqldb', SQLServer: 'sql01.test.local:1433'});
+CREATE (dbMappedUser:MSSQL_DatabaseUser:MSSQL_Base:Base {name: 'sqldb@CM_P01', SQLServer: 'sql01.test.local:1433'});
+CREATE (alterLogin:MSSQL_Login:MSSQL_Base:Base {name: 'TEST\\alter', SQLServer: 'sql01.test.local:1433'});
+CREATE (connectLogin:MSSQL_Login:MSSQL_Base:Base {name: 'TEST\\connectonly', SQLServer: 'sql01.test.local:1433'});
+
+CREATE (linkedSource:MSSQL_Server:MSSQL_Base:Base {
+ name: 'lab-sql01.test.local:1433',
+ sqlServerName: 'lab-sql01\\SQL01',
+ objectid: 'S-1-5-21-TEST-LABSQL:1433'
+});
+CREATE (linkedStub:MSSQL_Base {name: 'S-1-5-21-TEST-LABSQL:SQL01', objectid: 'S-1-5-21-TEST-LABSQL:SQL01'});
+CREATE (linkedTargetStub:MSSQL_Server:MSSQL_Base:Base {name: 'sql01.test.local'});
+CREATE (linkedLogin:MSSQL_Login:MSSQL_Base:Base {name: 'TEST\\linked', SQLServer: 'lab-sql01.test.local:1433'});
+CREATE (linkedGroupLogin:MSSQL_Login:MSSQL_Base:Base {name: 'TEST\\linked-group', SQLServer: 'lab-sql01.test.local:1433'});
+CREATE (linkedBroadLogin:MSSQL_Login:MSSQL_Base:Base {name: 'TEST\\Domain Users', SQLServer: 'lab-sql01.test.local:1433'});
+CREATE (execLogin:MSSQL_Login:MSSQL_Base:Base {name: 'TEST\\execchain', SQLServer: 'lab-sql01.test.local:1433'});
+CREATE (execAssumedLogin:MSSQL_Login:MSSQL_Base:Base {name: 'ReportSvc', SQLServer: 'lab-sql01.test.local:1433'});
+
+CREATE (sccmHost:Computer:Base {
+ name: 'SCCMSQL.TEST.LOCAL',
+ objectid: 'S-1-5-21-TEST-SCCM-SQL',
+ enabled: true,
+ domain: 'TEST.LOCAL',
+ SCCMSiteSystemRoles: ['SMS SQL Server@P01']
+});
+
+CREATE (disabledSource:MSSQL_Server:MSSQL_Base:Base {
+ name: 'disabled-src.test.local:1433',
+ sqlServerName: 'disabled-src\\SQL01',
+ objectid: 'S-1-5-21-TEST-DISABLED-SRC:1433'
+});
+CREATE (disabledStub:MSSQL_Base {name: 'S-1-5-21-TEST-DISABLED-SRC:SQL01', objectid: 'S-1-5-21-TEST-DISABLED-SRC:SQL01'});
+CREATE (disabledTargetStub:MSSQL_Server:MSSQL_Base:Base {name: 'disabled-sccm.test.local'});
+CREATE (disabledTarget:MSSQL_Server:MSSQL_Base:Base {name: 'disabled-sccm.test.local:1433'});
+CREATE (disabledLogin:MSSQL_Login:MSSQL_Base:Base {name: 'TEST\\disabledexec', SQLServer: 'disabled-src.test.local:1433'});
+CREATE (disabledAssumedLogin:MSSQL_Login:MSSQL_Base:Base {name: 'DisabledExecSvc', SQLServer: 'disabled-src.test.local:1433'});
+CREATE (disabledHost:Computer:Base {
+ name: 'DISABLED-SCCM.TEST.LOCAL',
+ objectid: 'S-1-5-21-TEST-DISABLED-HOST',
+ enabled: false,
+ domain: 'TEST.LOCAL',
+ SCCMSiteSystemRoles: ['SMS SQL Server@P01']
+});
+
+CREATE (noAdminSource:MSSQL_Server:MSSQL_Base:Base {
+ name: 'noadmin-src.test.local:1433',
+ sqlServerName: 'noadmin-src\\SQL01',
+ objectid: 'S-1-5-21-TEST-NOADMIN-SRC:1433'
+});
+CREATE (noAdminStub:MSSQL_Base {name: 'S-1-5-21-TEST-NOADMIN-SRC:SQL01', objectid: 'S-1-5-21-TEST-NOADMIN-SRC:SQL01'});
+CREATE (noAdminTargetStub:MSSQL_Server:MSSQL_Base:Base {name: 'noadmin-sccm.test.local'});
+CREATE (noAdminTarget:MSSQL_Server:MSSQL_Base:Base {name: 'noadmin-sccm.test.local:1433'});
+CREATE (noAdminLogin:MSSQL_Login:MSSQL_Base:Base {name: 'TEST\\noadminlink', SQLServer: 'noadmin-src.test.local:1433'});
+CREATE (noAdminAssumedLogin:MSSQL_Login:MSSQL_Base:Base {name: 'NoAdminExecSvc', SQLServer: 'noadmin-src.test.local:1433'});
+CREATE (noAdminHost:Computer:Base {
+ name: 'NOADMIN-SCCM.TEST.LOCAL',
+ objectid: 'S-1-5-21-TEST-NOADMIN-HOST',
+ enabled: true,
+ domain: 'TEST.LOCAL',
+ SCCMSiteSystemRoles: ['SMS SQL Server@P01']
+});
+
+MATCH (sccmServer:MSSQL_Server {name: 'sql01.test.local:1433'}),
+ (sccmDb:MSSQL_Database {name: 'CM_P01'}),
+ (site:SCCM_Site {siteCode: 'P01'}),
+ (sysadmin:MSSQL_ServerRole {name: 'sysadmin'}),
+ (dbOwner:MSSQL_DatabaseRole {name: 'db_owner@CM_P01'}),
+ (adminUser:SCCM_AdminUser {objectid: 'TEST\\SCCM Admins@P01'}),
+ (roleOnlyAdmin:SCCM_AdminUser {objectid: 'TEST\\Role Admin@P01'}),
+ (fullRole:SCCM_SecurityRole {name: 'Full Administrator'})
+CREATE (sccmServer)-[:MSSQL_Contains]->(sccmDb)
+CREATE (sccmServer)-[:MSSQL_Contains]->(sysadmin)
+CREATE (sysadmin)-[:MSSQL_ControlServer]->(sccmServer)
+CREATE (sccmDb)-[:MSSQL_Contains]->(dbOwner)
+CREATE (sccmDb)-[:SCCM_AssignAllPermissions]->(site)
+CREATE (adminUser)-[:SCCM_AllPermissions]->(site)
+CREATE (adminUser)-[:SCCM_IsAssigned]->(fullRole)
+CREATE (roleOnlyAdmin)-[:SCCM_IsAssigned]->(fullRole);
+
+MATCH (pureUser:User {objectid: 'S-1-5-21-TEST-100'}),
+ (nestedUser:User {objectid: 'S-1-5-21-TEST-101'}),
+ (sccmGroup:Group {objectid: 'S-1-5-21-TEST-SCCM-GROUP'}),
+ (adminUser:SCCM_AdminUser {objectid: 'TEST\\SCCM Admins@P01'}),
+ (n1:Group {objectid: 'S-1-5-21-TEST-N1'}),
+ (n2:Group {objectid: 'S-1-5-21-TEST-N2'}),
+ (n3:Group {objectid: 'S-1-5-21-TEST-N3'}),
+ (n4:Group {objectid: 'S-1-5-21-TEST-N4'}),
+ (n5:Group {objectid: 'S-1-5-21-TEST-N5'}),
+ (n6:Group {objectid: 'S-1-5-21-TEST-N6'})
+CREATE (pureUser)-[:MemberOf]->(sccmGroup)
+CREATE (sccmGroup)-[:SCCM_IsMappedTo]->(adminUser)
+CREATE (nestedUser)-[:MemberOf]->(n1)
+CREATE (n1)-[:MemberOf]->(n2)
+CREATE (n2)-[:MemberOf]->(n3)
+CREATE (n3)-[:MemberOf]->(n4)
+CREATE (n4)-[:MemberOf]->(n5)
+CREATE (n5)-[:MemberOf]->(n6)
+CREATE (n6)-[:SCCM_IsMappedTo]->(adminUser);
+
+MATCH (sqlUser:User {objectid: 'S-1-5-21-TEST-102'}),
+ (sqlLogin:MSSQL_Login {name: 'TEST\\sqlsys'}),
+ (sysadmin:MSSQL_ServerRole {name: 'sysadmin'}),
+ (dbUserPrincipal:User {objectid: 'S-1-5-21-TEST-103'}),
+ (dbLogin:MSSQL_Login {name: 'TEST\\sqldb'}),
+ (dbMappedUser:MSSQL_DatabaseUser {name: 'sqldb@CM_P01'}),
+ (dbOwner:MSSQL_DatabaseRole {name: 'db_owner@CM_P01'}),
+ (alterUser:User {objectid: 'S-1-5-21-TEST-105'}),
+ (alterLogin:MSSQL_Login {name: 'TEST\\alter'}),
+ (connectOnlyUser:User {objectid: 'S-1-5-21-TEST-200'}),
+ (connectLogin:MSSQL_Login {name: 'TEST\\connectonly'}),
+ (sccmServer:MSSQL_Server {name: 'sql01.test.local:1433'})
+CREATE (sqlUser)-[:MSSQL_HasLogin]->(sqlLogin)
+CREATE (sqlLogin)-[:MSSQL_MemberOf]->(sysadmin)
+CREATE (dbUserPrincipal)-[:MSSQL_HasLogin]->(dbLogin)
+CREATE (dbLogin)-[:MSSQL_IsMappedTo]->(dbMappedUser)
+CREATE (dbMappedUser)-[:MSSQL_MemberOf]->(dbOwner)
+CREATE (alterUser)-[:MSSQL_HasLogin]->(alterLogin)
+CREATE (alterLogin)-[:MSSQL_Connect]->(sccmServer)
+CREATE (alterLogin)-[:MSSQL_AlterAnyLogin]->(sccmServer)
+CREATE (connectOnlyUser)-[:MSSQL_HasLogin]->(connectLogin)
+CREATE (connectLogin)-[:MSSQL_Connect]->(sccmServer);
+
+MATCH (linkedUser:User {objectid: 'S-1-5-21-TEST-104'}),
+ (groupLinkedUser:User {objectid: 'S-1-5-21-TEST-107'}),
+ (broadLinkedUser:User {objectid: 'S-1-5-21-TEST-108'}),
+ (linkedSqlGroup:Group {objectid: 'S-1-5-21-TEST-LINKED-SQL-GROUP'}),
+ (domainUsers:Group {objectid: 'S-1-5-21-TEST-513'}),
+ (linkedLogin:MSSQL_Login {name: 'TEST\\linked'}),
+ (linkedGroupLogin:MSSQL_Login {name: 'TEST\\linked-group'}),
+ (linkedBroadLogin:MSSQL_Login {name: 'TEST\\Domain Users'}),
+ (linkedSource:MSSQL_Server {name: 'lab-sql01.test.local:1433'}),
+ (linkedStub:MSSQL_Base {objectid: 'S-1-5-21-TEST-LABSQL:SQL01'}),
+ (linkedTargetStub:MSSQL_Server {name: 'sql01.test.local'})
+CREATE (linkedUser)-[:MSSQL_HasLogin]->(linkedLogin)
+CREATE (linkedLogin)-[:MSSQL_Connect]->(linkedSource)
+CREATE (groupLinkedUser)-[:MemberOf]->(linkedSqlGroup)
+CREATE (linkedSqlGroup)-[:MSSQL_HasLogin]->(linkedGroupLogin)
+CREATE (linkedGroupLogin)-[:MSSQL_Connect]->(linkedSource)
+CREATE (broadLinkedUser)-[:MemberOf]->(domainUsers)
+CREATE (domainUsers)-[:MSSQL_HasLogin]->(linkedBroadLogin)
+CREATE (linkedBroadLogin)-[:MSSQL_Connect]->(linkedSource)
+CREATE (linkedStub)-[:MSSQL_LinkedAsAdmin]->(linkedTargetStub);
+
+MATCH (execHostUser:User {objectid: 'S-1-5-21-TEST-109'}),
+ (execLogin:MSSQL_Login {name: 'TEST\\execchain'}),
+ (execAssumedLogin:MSSQL_Login {name: 'ReportSvc'}),
+ (linkedSource:MSSQL_Server {name: 'lab-sql01.test.local:1433'}),
+ (sccmServer:MSSQL_Server {name: 'sql01.test.local:1433'}),
+ (sqlServiceAccount:User {objectid: 'S-1-5-21-TEST-SQLSVC'}),
+ (sccmHost:Computer {objectid: 'S-1-5-21-TEST-SCCM-SQL'}),
+ (site:SCCM_Site {siteCode: 'P01'})
+CREATE (execHostUser)-[:MSSQL_HasLogin]->(execLogin)
+CREATE (execLogin)-[:MSSQL_Connect]->(linkedSource)
+CREATE (execLogin)-[:MSSQL_ExecuteAs]->(execAssumedLogin)
+CREATE (execAssumedLogin)-[:MSSQL_Connect]->(linkedSource)
+CREATE (sqlServiceAccount)-[:MSSQL_ServiceAccountFor]->(sccmServer)
+CREATE (sccmServer)-[:MSSQL_ExecuteOnHost]->(sccmHost)
+CREATE (sccmHost)-[:SCCM_AssignAllPermissions]->(site);
+
+MATCH (disabledExecUser:User {objectid: 'S-1-5-21-TEST-203'}),
+ (disabledLogin:MSSQL_Login {name: 'TEST\\disabledexec'}),
+ (disabledAssumedLogin:MSSQL_Login {name: 'DisabledExecSvc'}),
+ (disabledSource:MSSQL_Server {name: 'disabled-src.test.local:1433'}),
+ (disabledStub:MSSQL_Base {objectid: 'S-1-5-21-TEST-DISABLED-SRC:SQL01'}),
+ (disabledTargetStub:MSSQL_Server {name: 'disabled-sccm.test.local'}),
+ (disabledTarget:MSSQL_Server {name: 'disabled-sccm.test.local:1433'}),
+ (disabledHost:Computer {objectid: 'S-1-5-21-TEST-DISABLED-HOST'}),
+ (site:SCCM_Site {siteCode: 'P01'})
+CREATE (disabledExecUser)-[:MSSQL_HasLogin]->(disabledLogin)
+CREATE (disabledLogin)-[:MSSQL_ExecuteAs]->(disabledAssumedLogin)
+CREATE (disabledAssumedLogin)-[:MSSQL_Connect]->(disabledSource)
+CREATE (disabledStub)-[:MSSQL_LinkedAsAdmin {rpcOut: true}]->(disabledTargetStub)
+CREATE (disabledTarget)-[:MSSQL_ExecuteOnHost]->(disabledHost)
+CREATE (disabledHost)-[:SCCM_AssignAllPermissions]->(site);
+
+MATCH (noAdminLinkUser:User {objectid: 'S-1-5-21-TEST-204'}),
+ (noAdminLogin:MSSQL_Login {name: 'TEST\\noadminlink'}),
+ (noAdminAssumedLogin:MSSQL_Login {name: 'NoAdminExecSvc'}),
+ (noAdminSource:MSSQL_Server {name: 'noadmin-src.test.local:1433'}),
+ (noAdminStub:MSSQL_Base {objectid: 'S-1-5-21-TEST-NOADMIN-SRC:SQL01'}),
+ (noAdminTargetStub:MSSQL_Server {name: 'noadmin-sccm.test.local'}),
+ (noAdminTarget:MSSQL_Server {name: 'noadmin-sccm.test.local:1433'}),
+ (noAdminHost:Computer {objectid: 'S-1-5-21-TEST-NOADMIN-HOST'}),
+ (site:SCCM_Site {siteCode: 'P01'})
+CREATE (noAdminLinkUser)-[:MSSQL_HasLogin]->(noAdminLogin)
+CREATE (noAdminLogin)-[:MSSQL_ExecuteAs]->(noAdminAssumedLogin)
+CREATE (noAdminAssumedLogin)-[:MSSQL_Connect]->(noAdminSource)
+CREATE (noAdminStub)-[:MSSQL_LinkedTo {
+ rpcOut: true,
+ remoteIsSysadmin: false,
+ remoteIsSecurityAdmin: false,
+ remoteHasControlServer: false,
+ remoteHasImpersonateAnyLogin: false
+}]->(noAdminTargetStub)
+CREATE (noAdminTarget)-[:MSSQL_ExecuteOnHost]->(noAdminHost)
+CREATE (noAdminHost)-[:SCCM_AssignAllPermissions]->(site);
+
+MATCH (roleOnlyUser:User {objectid: 'S-1-5-21-TEST-106'}),
+ (roleOnlyAdmin:SCCM_AdminUser {objectid: 'TEST\\Role Admin@P01'})
+CREATE (roleOnlyUser)-[:SCCM_IsMappedTo]->(roleOnlyAdmin);
+
+MATCH (lowSccmUser:User {objectid: 'S-1-5-21-TEST-201'}),
+ (lowGroup:Group {objectid: 'S-1-5-21-TEST-LOW-GROUP'}),
+ (lowAdmin:SCCM_AdminUser {objectid: 'TEST\\Low SCCM Group@P01'})
+CREATE (lowSccmUser)-[:MemberOf]->(lowGroup)
+CREATE (lowGroup)-[:SCCM_IsMappedTo]->(lowAdmin);
+
+MATCH (crossForestUser:User {objectid: 'S-1-5-21-TEST-202'}),
+ (otherGroup:Group {objectid: 'S-1-5-21-TEST-OTHER-GROUP'}),
+ (otherAdmin:SCCM_AdminUser {objectid: 'OTHER\\SCCM Admins@X01'}),
+ (otherSite:SCCM_Site {siteCode: 'X01'})
+CREATE (crossForestUser)-[:MemberOf]->(otherGroup)
+CREATE (otherGroup)-[:SCCM_IsMappedTo]->(otherAdmin)
+CREATE (otherAdmin)-[:SCCM_AllPermissions]->(otherSite);
diff --git a/tests/integration/test_pipeline_fingerprint.py b/tests/integration/test_pipeline_fingerprint.py
index 2c87b7e..c7f8cf0 100644
--- a/tests/integration/test_pipeline_fingerprint.py
+++ b/tests/integration/test_pipeline_fingerprint.py
@@ -52,7 +52,7 @@ def _missing_env():
"ESC1 — Enrollee Supplies Subject",
"MSSQL Login",
"MSSQL Privilege Escalation",
- "SCCM Database Compromise",
+ "SCCM Privilege Escalation",
"KRBTGT Password Older Than 6 Months",
"Computers with SMB Signing Disabled",
]
diff --git a/tests/test_check_inventory.py b/tests/test_check_inventory.py
index fc6293c..d47af80 100644
--- a/tests/test_check_inventory.py
+++ b/tests/test_check_inventory.py
@@ -21,6 +21,19 @@
"tier0_session_exposure_tier0_computer",
"cross_domain_escalation_path_via_trust",
"cross_domain_computer_escalation_path_via_trust",
+ "dedupe_mssql_servers",
+ "canonicalize_linked_server",
+ "mssql_privilege_escalation_linked_server",
+ "mssql_priv_esc_group_owned_login",
+ "mssql_priv_esc_linked_server_sid",
+ "mssql_priv_esc_linked_server_overmatch",
+ "mssql_priv_esc_linked_server_name_overmatch",
+ "mssql_priv_esc_linked_server_empty_stub",
+ "mssql_priv_esc_linked_server_shared_host_sid",
+ "mssql_priv_esc_owns_server_role",
+ "sccm_priv_esc_linked_server_name_overmatch",
+ "sccm_priv_esc_linked_server_empty_stub",
+ "sccm_priv_esc_linked_server_shared_host_sid",
}
diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py
index 1b01386..e6d6742 100644
--- a/tests/test_diagnostics.py
+++ b/tests/test_diagnostics.py
@@ -100,6 +100,11 @@ def test_error_converted_to_string(self):
dc.record_error("src", Exception(42))
self.assertEqual(dc.errors[0]["error"], "42")
+ def test_warning_converted_to_string(self):
+ dc = DiagnosticsCollector()
+ dc.record_warning("src", Warning("heads up"))
+ self.assertEqual(dc.warnings[0]["warning"], "heads up")
+
class TestToDict(unittest.TestCase):
def test_all_top_level_keys(self):
@@ -110,7 +115,7 @@ def test_all_top_level_keys(self):
"checks", "escalation_paths", "escalation_batches",
"password_audit", "cross_domain_checks", "mssql_sccm",
"check_ordering", "report_generation",
- "errors",
+ "warnings", "errors",
}
self.assertEqual(set(result.keys()), expected)
diff --git a/tests/test_display_spacing.py b/tests/test_display_spacing.py
new file mode 100644
index 0000000..4e76b38
--- /dev/null
+++ b/tests/test_display_spacing.py
@@ -0,0 +1,196 @@
+from checks.core.constants import DataTypes
+from checks.core.display.escalation import (
+ EscalationPathDisplayHandler,
+ GroupedEscalationDisplayHandler,
+)
+from checks.core.display.group_analysis import GroupAnalysisDisplayHandler
+from checks.core.display.shared_graph_paths import SharedGraphPathDisplayHandler
+from checks.core.display.simple import SimpleDisplayHandler
+from checks.cross_domain.injector import _display_grouped_paths, _display_results
+
+
+class FakeSidMapper:
+ def __init__(self, mapping=None):
+ self.mapping = mapping or {}
+
+ def get_display_name(self, sid):
+ return self.mapping.get(sid, sid)
+
+
+class FakeCheck:
+ ENTITY_TYPE = 'user'
+ REQUIRED_DATA = []
+
+ def __init__(self):
+ self.data = {}
+ self.neo4j_data = type('Neo4jData', (), {})()
+ self.account_analysis = None
+
+
+def test_simple_display_inserts_blank_line_between_multiple_items():
+ handler = SimpleDisplayHandler(
+ suppress_terminal_output=True,
+ check_instance=FakeCheck(),
+ sid_mapper=FakeSidMapper(),
+ )
+
+ content = []
+ handler.display(
+ {
+ 'ESC1 (TRAINING-DC01-CA ON DC01.TRAINING.LOCAL)': 'AUTHENTICATED USERS, DOMAIN USERS',
+ 'ESC2 (TRAINING-DC01-CA ON DC01.TRAINING.LOCAL)': 'AUTHENTICATED USERS, DOMAIN USERS',
+ },
+ '',
+ 'ESC1 - Enrollee Supplies Subject',
+ content,
+ 2,
+ '',
+ )
+
+ assert content == [
+ '\n ESC1 - Enrollee Supplies Subject: 2',
+ ' ▶ ESC1 (TRAINING-DC01-CA ON DC01.TRAINING.LOCAL)',
+ ' ▶ AUTHENTICATED USERS, DOMAIN USERS',
+ '',
+ ' ▶ ESC2 (TRAINING-DC01-CA ON DC01.TRAINING.LOCAL)',
+ ' ▶ AUTHENTICATED USERS, DOMAIN USERS',
+ ]
+
+
+def test_escalation_path_display_inserts_blank_line_between_entities():
+ handler = EscalationPathDisplayHandler(
+ suppress_terminal_output=True,
+ check_instance=FakeCheck(),
+ sid_mapper=FakeSidMapper({'S-1-1': 'HOST1', 'S-1-2': 'HOST2'}),
+ )
+
+ content = []
+ handler.display({'S-1-1': '', 'S-1-2': ''}, '', 'Escalation Paths', content, 2, '')
+
+ assert content == [
+ '\n Escalation Paths: 2',
+ ' ▶ HOST1',
+ '',
+ ' ▶ HOST2',
+ ]
+
+
+def test_grouped_escalation_display_separates_entities_from_common_path():
+ check = FakeCheck()
+ path = [
+ {'name': 'ALICE@TEST.LOCAL', 'labels': ['User']},
+ 'MemberOf',
+ {'name': 'DOMAIN ADMINS@TEST.LOCAL', 'labels': ['Group']},
+ ]
+ check.data = {
+ DataTypes.FULL_ESCALATION_PATHS: {
+ 'S-1-1': [{'fullPath': path}],
+ 'S-1-2': [{'fullPath': path}],
+ }
+ }
+ handler = GroupedEscalationDisplayHandler(
+ suppress_terminal_output=True,
+ check_instance=check,
+ sid_mapper=FakeSidMapper({'S-1-1': 'ALICE', 'S-1-2': 'BOB'}),
+ )
+
+ content = []
+ handler.display({'S-1-1': '', 'S-1-2': ''}, '', 'Non-admin Users with Escalation Paths', content, 2, '')
+
+ summary_index = content.index(' Users with Shared Path (2): ALICE, BOB')
+ assert content[summary_index + 1] == ''
+ assert content[summary_index + 2].startswith(' Common Escalation Path: ')
+
+
+def test_group_analysis_display_separates_multiple_paths_under_same_group():
+ class AccountAnalysis:
+ def _resolve_target_type(self, *_args):
+ return 'User'
+
+ check = FakeCheck()
+ check.account_analysis = AccountAnalysis()
+ handler = GroupAnalysisDisplayHandler(
+ suppress_terminal_output=True,
+ check_instance=check,
+ sid_mapper=FakeSidMapper(),
+ )
+
+ content = []
+ handler.display(
+ {
+ 'DOMAIN USERS': [
+ {'rel_type': 'GenericAll', 'target': 'TARGET1', 'targetSID': 'S-1-1'},
+ {'rel_type': 'WriteDacl', 'target': 'TARGET2', 'targetSID': 'S-1-2'},
+ ]
+ },
+ '',
+ 'Common Groups with Privilege Escalation Paths',
+ content,
+ 1,
+ '',
+ )
+
+ first_path = content.index(' ├─ GenericAll on TARGET1 (User)')
+ assert content[first_path + 1] == ''
+ assert content[first_path + 2] == ' └─ WriteDacl on TARGET2 (User)'
+
+
+def test_shared_graph_paths_display_inserts_blank_line_between_entities():
+ handler = SharedGraphPathDisplayHandler(
+ suppress_terminal_output=True,
+ check_instance=FakeCheck(),
+ sid_mapper=FakeSidMapper({'S-1-1': 'USER1', 'S-1-2': 'USER2'}),
+ )
+
+ content = []
+ handler.display(
+ {
+ 'S-1-1': 'user1 -> MSSQL_Connect -> sql01',
+ 'S-1-2': 'user2 -> MSSQL_Connect -> sql01',
+ },
+ '',
+ 'MSSQL Privilege Escalation',
+ content,
+ 2,
+ '',
+ )
+
+ assert content == [
+ '\n MSSQL Privilege Escalation: 2',
+ ' ▶ USER1',
+ ' user1 > MSSQL_Connect > sql01',
+ '',
+ ' ▶ USER2',
+ ' user2 > MSSQL_Connect > sql01',
+ ]
+
+
+def test_cross_domain_standard_display_inserts_blank_line_between_items():
+ content = []
+ _display_results({'ONE': '', 'TWO': ''}, '', 'Cross-Domain', content, 2, '')
+
+ assert content == [
+ '\n Cross-Domain: 2',
+ ' ▶ ONE',
+ '',
+ ' ▶ TWO',
+ ]
+
+
+def test_cross_domain_grouped_display_separates_summary_from_path():
+ content = []
+ _display_grouped_paths(
+ {'Users with Shared Path (2): ALICE, BOB': 'Cross-Domain Path: MemberOf -> DOMAIN ADMINS'},
+ '',
+ 'Cross-Domain Escalation Paths',
+ content,
+ 2,
+ '',
+ )
+
+ assert content == [
+ '\n Cross-Domain Escalation Paths: 2',
+ ' Users with Shared Path (2): ALICE, BOB',
+ '',
+ ' Cross-Domain Path: MemberOf -> DOMAIN ADMINS',
+ ]
diff --git a/tests/test_importer_canonicalize_neo4j.py b/tests/test_importer_canonicalize_neo4j.py
new file mode 100644
index 0000000..db8946b
--- /dev/null
+++ b/tests/test_importer_canonicalize_neo4j.py
@@ -0,0 +1,53 @@
+import pytest
+
+from modules.BloodhoundImporter import BloodhoundImporter
+from tests.check_harness import load_fixture
+
+pytestmark = pytest.mark.neo4j
+
+
+def _linked_edge_count(conn):
+ rows = conn.query(
+ "MATCH ()-[r:MSSQL_LinkedAsAdmin|MSSQL_LinkedTo]->() RETURN count(r) AS c"
+ )
+ return rows[0]["c"]
+
+
+def test_canonicalizer_creates_source_to_target_edges(clean_neo4j):
+ load_fixture(clean_neo4j, "canonicalize_linked_server.cypher")
+ importer = BloodhoundImporter(None)
+ importer.connection = clean_neo4j
+
+ merged = importer._canonicalize_mssql_linked_server_edges()
+
+ assert merged == 2
+ admin = clean_neo4j.query(
+ "MATCH (s:MSSQL_Server {objectid: 'S-1-5-21-TEST-CANON-A:1433'})"
+ "-[:MSSQL_LinkedAsAdmin]->(t:MSSQL_Server {objectid: 'S-1-5-21-TEST-TGT-A:1433'}) "
+ "RETURN count(*) AS c"
+ )
+ linked_to = clean_neo4j.query(
+ "MATCH (s:MSSQL_Server {objectid: 'S-1-5-21-TEST-CANON-B:1433'})"
+ "-[:MSSQL_LinkedTo]->(t:MSSQL_Server {objectid: 'S-1-5-21-TEST-TGT-B:1433'}) "
+ "RETURN count(*) AS c"
+ )
+ assert admin[0]["c"] == 1
+ assert linked_to[0]["c"] == 1
+
+
+def test_canonicalizer_is_idempotent_on_reimport(clean_neo4j):
+ load_fixture(clean_neo4j, "canonicalize_linked_server.cypher")
+ importer = BloodhoundImporter(None)
+ importer.connection = clean_neo4j
+
+ first = importer._canonicalize_mssql_linked_server_edges()
+ edges_after_first = _linked_edge_count(clean_neo4j)
+
+ second = importer._canonicalize_mssql_linked_server_edges()
+ edges_after_second = _linked_edge_count(clean_neo4j)
+
+ # MERGE reports ensured edges on re-import without creating duplicates.
+ assert first == 2
+ assert second == 2
+ assert edges_after_first == 4
+ assert edges_after_second == 4
diff --git a/tests/test_importer_dedupe_neo4j.py b/tests/test_importer_dedupe_neo4j.py
new file mode 100644
index 0000000..01b1109
--- /dev/null
+++ b/tests/test_importer_dedupe_neo4j.py
@@ -0,0 +1,130 @@
+import pytest
+
+from modules.BloodhoundImporter import BloodhoundImporter
+from tests.check_harness import load_fixture
+
+pytestmark = pytest.mark.neo4j
+
+
+def test_dedupe_collapses_duplicate_mssql_server_names(clean_neo4j):
+ load_fixture(clean_neo4j, "dedupe_mssql_servers.cypher")
+
+ importer = BloodhoundImporter(None)
+ importer.connection = clean_neo4j
+
+ importer._dedupe_mssql_servers()
+
+ sccmdb_nodes = clean_neo4j.query(
+ "MATCH (s:MSSQL_Server) WHERE toLower(s.name) = 'sccmdb.training.local:1433' "
+ "RETURN s.objectid AS oid ORDER BY oid"
+ )
+ assert [r['oid'] for r in sccmdb_nodes] == ['sccmdb:1433']
+
+ sql01_nodes = clean_neo4j.query(
+ "MATCH (s:MSSQL_Server) WHERE toLower(s.name) STARTS WITH 'sql01.training.local' "
+ "RETURN s.objectid AS oid ORDER BY s.name"
+ )
+ assert [r['oid'] for r in sql01_nodes] == [
+ 'sql01\\sql01:1433',
+ 'sql01\\app1:1444',
+ 'sql01\\app2:1455',
+ ]
+
+
+def test_dedupe_preserves_stale_only_properties_on_canonical(clean_neo4j):
+ load_fixture(clean_neo4j, "dedupe_mssql_servers.cypher")
+ importer = BloodhoundImporter(None)
+ importer.connection = clean_neo4j
+
+ importer._dedupe_mssql_servers()
+
+ rows = clean_neo4j.query(
+ "MATCH (s:MSSQL_Server {objectid: 'sccmdb:1433'}) "
+ "RETURN s.version AS version, s.xpCmdShellEnabled AS xp, "
+ "s.extendedProtection AS epa, s.sqlServerName AS svc"
+ )
+ assert rows[0]['version'] == '15.0.4322.2'
+ assert rows[0]['xp'] is False
+ assert rows[0]['epa'] == 'Off'
+ assert rows[0]['svc'] == 'SCCMDB'
+
+
+def test_dedupe_reroutes_relationships_from_stale_to_canonical(clean_neo4j):
+ load_fixture(clean_neo4j, "dedupe_mssql_servers.cypher")
+ importer = BloodhoundImporter(None)
+ importer.connection = clean_neo4j
+
+ importer._dedupe_mssql_servers()
+
+ rows = clean_neo4j.query(
+ "MATCH (canonical:MSSQL_Server {objectid: 'sccmdb:1433'})-[:MSSQL_Contains]->(role:MSSQL_ServerRole {name: 'sysadmin'}) "
+ "RETURN count(role) AS c"
+ )
+ assert rows[0]['c'] == 1
+
+
+def test_dedupe_preserves_existing_canonical_relationship_properties(clean_neo4j):
+ clean_neo4j.query("""
+ CREATE (canonical:MSSQL_Server:MSSQL_Base:Base {
+ objectid: 'canonical:1433',
+ name: 'sql.training.local:1433',
+ sqlServerName: 'SQL',
+ version: '15.0'
+ })
+ CREATE (stale:MSSQL_Server:MSSQL_Base:Base {
+ objectid: 'stale:1433',
+ name: 'SQL.TRAINING.LOCAL:1433'
+ })
+ CREATE (role:MSSQL_ServerRole:MSSQL_Base:Base {
+ objectid: 'sysadmin@sql',
+ name: 'sysadmin'
+ })
+ CREATE (canonical)-[:MSSQL_Contains {
+ canonicalOnly: 'yes',
+ shared: 'canonical'
+ }]->(role)
+ CREATE (stale)-[:MSSQL_Contains {
+ staleOnly: 'yes',
+ shared: 'stale'
+ }]->(role)
+ """)
+ importer = BloodhoundImporter(None)
+ importer.connection = clean_neo4j
+
+ importer._dedupe_mssql_servers()
+
+ rows = clean_neo4j.query(
+ "MATCH (:MSSQL_Server {objectid: 'canonical:1433'})-[r:MSSQL_Contains]->(:MSSQL_ServerRole {name: 'sysadmin'}) "
+ "RETURN count(r) AS c, r.canonicalOnly AS canonicalOnly, r.staleOnly AS staleOnly, r.shared AS shared"
+ )
+ assert rows[0]['c'] == 1
+ assert rows[0]['canonicalOnly'] == 'yes'
+ assert rows[0]['staleOnly'] == 'yes'
+ assert rows[0]['shared'] == 'canonical'
+
+
+def test_dedupe_does_not_rewrite_stale_canonical_edges_as_self_loops(clean_neo4j):
+ clean_neo4j.query("""
+ CREATE (canonical:MSSQL_Server:MSSQL_Base:Base {
+ objectid: 'canonical:1433',
+ name: 'sql.training.local:1433',
+ sqlServerName: 'SQL',
+ version: '15.0'
+ })
+ CREATE (stale:MSSQL_Server:MSSQL_Base:Base {
+ objectid: 'stale:1433',
+ name: 'SQL.TRAINING.LOCAL:1433'
+ })
+ CREATE (canonical)-[:MSSQL_LinkedAsAdmin]->(stale)
+ CREATE (stale)-[:MSSQL_LinkedAsAdmin]->(canonical)
+ """)
+ importer = BloodhoundImporter(None)
+ importer.connection = clean_neo4j
+
+ importer._dedupe_mssql_servers()
+
+ rows = clean_neo4j.query(
+ "MATCH (canonical:MSSQL_Server {objectid: 'canonical:1433'})-[r]->(canonical) "
+ "RETURN count(r) AS c"
+ )
+ assert rows[0]['c'] == 0
diff --git a/tests/test_importer_opengraph.py b/tests/test_importer_opengraph.py
index 1a5d4da..e87c990 100644
--- a/tests/test_importer_opengraph.py
+++ b/tests/test_importer_opengraph.py
@@ -10,6 +10,7 @@
_normalise_neo4j_properties,
)
from modules.collectors.mssqlhound import _mssql_identity
+from modules.diagnostics import DiagnosticsCollector
from modules.opengraph_contracts import (
OpenGraphRequirement,
OpenGraphRequirementWarning,
@@ -886,6 +887,9 @@ def test_import_warns_once_for_dropped_properties(capsys):
def test_upload_warns_when_mssql_servers_lack_host_edges(capsys):
conn = RecordingConnection(
responses=[
+ [],
+ [],
+ [],
[],
[{"missing": 1, "examples": ["sql01.example.local"]}],
]
@@ -936,6 +940,9 @@ def test_print_opengraph_requirement_warning_formats_more_suffix_without_used_by
def test_upload_does_not_warn_when_mssql_host_mapping_exists(capsys):
conn = RecordingConnection(
responses=[
+ [],
+ [],
+ [],
[],
[{"missing": 0, "examples": []}],
]
@@ -994,6 +1001,9 @@ def fake_prime_from_checks():
)
conn = RecordingConnection(
responses=[
+ [],
+ [],
+ [],
[],
[{"missing": 1, "examples": ["build-release"]}],
]
@@ -1563,31 +1573,32 @@ def test_deduplicate_by_identity_suppresses_same_instance_views(capsys):
assert "OpenGraph identity dedupe: skipped 1 duplicate" in capsys.readouterr().out
-def test_deduplicate_by_identity_matches_normal_mssqlhound_files(capsys):
+def test_deduplicate_by_identity_keeps_distinct_normal_mssqlhound_files(capsys):
importer = BloodhoundImporter(None)
zip_path = Path("sample_data/MSSQLHound.zip")
entries = []
+ expected_identities = {
+ "mssql-lab-sql01.training.local_SQL01.json": "lab-sql01.training.local|instance|sql01",
+ "mssql-sccmdb.training.local.json": "sccmdb.training.local|port|1433",
+ }
+
with zipfile.ZipFile(zip_path) as archive:
- for name in (
- "mssql-lab-sql01.training.local.json",
- "mssql-lab-sql01.training.local_SQL01.json",
- ):
+ missing = set(expected_identities) - set(archive.namelist())
+ assert not missing
+
+ for name, expected_identity in expected_identities.items():
data = json.loads(archive.read(name).decode("utf-8-sig"))
manifest = importer._select_collector_manifest(data)
entries.append((Path(name), "opengraph", data))
assert manifest is not None
assert manifest.source_kind == "MSSQL_Base"
- assert importer._collector_identity(manifest, data) == (
- "lab-sql01.training.local|instance|sql01"
- )
+ assert importer._collector_identity(manifest, data) == expected_identity
deduped = importer._deduplicate_by_identity(entries)
- assert [entry[0].name for entry in deduped] == [
- "mssql-lab-sql01.training.local_SQL01.json"
- ]
- assert "OpenGraph identity dedupe: skipped 1 duplicate" in capsys.readouterr().out
+ assert [entry[0].name for entry in deduped] == list(expected_identities)
+ assert "OpenGraph identity dedupe" not in capsys.readouterr().out
@pytest.mark.parametrize(
@@ -1672,3 +1683,296 @@ def test_merge_orphan_stubs_refuses_broad_labels_before_query(label):
importer._merge_orphan_stubs(label)
assert conn.queries == []
+
+
+def test_opengraph_skips_empty_id_nodes_with_warning(capsys):
+ conn = RecordingConnection()
+ importer = BloodhoundImporter(conn)
+ data = _opengraph(
+ "Mixed_Base",
+ [
+ {"id": "good-1", "kinds": ["Base"], "properties": {"name": "good"}},
+ {"id": "", "kinds": ["Computer", "Base"], "properties": {}},
+ {"id": "good-2", "kinds": ["Base"], "properties": {"name": "alsogood"}},
+ ],
+ )
+
+ importer._validate_opengraph_payloads([("computers.json", data, "opengraph")])
+
+ output = capsys.readouterr().out
+ assert "computers.json: skipped 1 node(s) with empty id" in output
+ assert [n.get("id") for n in data["graph"]["nodes"]] == ["good-1", "good-2"]
+
+
+def test_opengraph_skip_empty_id_does_not_repeat_warning_on_second_validation(capsys):
+ conn = RecordingConnection()
+ importer = BloodhoundImporter(conn)
+ data = _opengraph(
+ "Mixed_Base",
+ [
+ {"id": "good-1", "kinds": ["Base"], "properties": {"name": "good"}},
+ {"id": "", "kinds": ["Computer", "Base"], "properties": {}},
+ ],
+ )
+
+ importer._validate_opengraph_payloads([("computers.json", data, "opengraph")])
+ capsys.readouterr() # drain
+ importer._validate_opengraph_payloads([("computers.json", data, "opengraph")])
+ second = capsys.readouterr().out
+ assert "skipped" not in second
+
+
+def test_opengraph_resolves_empty_id_via_dnshostname(capsys):
+ conn = RecordingConnection()
+ importer = BloodhoundImporter(conn)
+ data = _opengraph(
+ "Mixed_Base",
+ [
+ {
+ "id": "S-1-5-21-999-1110",
+ "kinds": ["Computer", "Base"],
+ "properties": {"name": "SQL01.LAB.LOCAL", "DNSHostName": "sql01.lab.local"},
+ },
+ {
+ "id": "",
+ "kinds": ["Computer", "Base"],
+ "properties": {"DNSHostName": "SQL01.LAB.LOCAL", "SCCMSiteSystemRoles": "MP,DP"},
+ },
+ {
+ "id": "S-1-5-21-999-1111",
+ "kinds": ["Computer", "Base"],
+ "properties": {"name": "DC01.LAB.LOCAL"},
+ },
+ ],
+ )
+
+ importer._validate_opengraph_payloads([("computers.json", data, "opengraph")])
+
+ output = capsys.readouterr().out
+ assert "resolved 1 empty-id node(s) via DNSHostName" in output
+ assert "skipped" not in output
+ ids = [n.get("id") for n in data["graph"]["nodes"]]
+ assert ids == ["S-1-5-21-999-1110", "S-1-5-21-999-1110", "S-1-5-21-999-1111"]
+
+
+def test_opengraph_empty_id_ambiguous_dns_is_skipped(capsys):
+ conn = RecordingConnection()
+ importer = BloodhoundImporter(conn)
+ data = _opengraph(
+ "Mixed_Base",
+ [
+ {
+ "id": "S-1-5-21-999-1110",
+ "kinds": ["Computer", "Base"],
+ "properties": {"name": "SQL01A.LAB.LOCAL", "DNSHostName": "sql01.lab.local"},
+ },
+ {
+ "id": "S-1-5-21-999-2220",
+ "kinds": ["Computer", "Base"],
+ "properties": {"name": "SQL01B.LAB.LOCAL", "DNSHostName": "SQL01.LAB.LOCAL"},
+ },
+ {
+ "id": "",
+ "kinds": ["Computer", "Base"],
+ "properties": {"DNSHostName": "sql01.lab.local"},
+ },
+ ],
+ )
+
+ importer._validate_opengraph_payloads([("computers.json", data, "opengraph")])
+
+ output = capsys.readouterr().out
+ assert "ambiguous DNSHostName" in output
+ assert "resolved" not in output
+ assert [n.get("id") for n in data["graph"]["nodes"]] == [
+ "S-1-5-21-999-1110",
+ "S-1-5-21-999-2220",
+ ]
+
+
+def test_opengraph_empty_id_no_dns_match_still_skipped(capsys):
+ conn = RecordingConnection()
+ importer = BloodhoundImporter(conn)
+ data = _opengraph(
+ "Mixed_Base",
+ [
+ {
+ "id": "S-1-5-21-999-1110",
+ "kinds": ["Computer", "Base"],
+ "properties": {"name": "SQL01.LAB.LOCAL", "DNSHostName": "sql01.lab.local"},
+ },
+ {
+ "id": "",
+ "kinds": ["Computer", "Base"],
+ "properties": {"DNSHostName": "UNKNOWN.LAB.LOCAL"},
+ },
+ ],
+ )
+
+ importer._validate_opengraph_payloads([("computers.json", data, "opengraph")])
+
+ output = capsys.readouterr().out
+ assert "skipped 1 node(s) with empty id" in output
+ assert "resolved" not in output
+ assert [n.get("id") for n in data["graph"]["nodes"]] == ["S-1-5-21-999-1110"]
+
+
+def test_opengraph_empty_id_warning_recorded_in_diagnostics(capsys):
+ diagnostics = DiagnosticsCollector()
+ conn = RecordingConnection()
+ importer = BloodhoundImporter(conn, diagnostics=diagnostics)
+ data = _opengraph(
+ "Mixed_Base",
+ [
+ {"id": "good-1", "kinds": ["Base"], "properties": {"name": "good"}},
+ {"id": "", "kinds": ["Computer", "Base"], "properties": {}},
+ ],
+ )
+
+ importer._validate_opengraph_payloads([("computers.json", data, "opengraph")])
+
+ capsys.readouterr()
+ assert len(diagnostics.warnings) == 1
+ warning = diagnostics.warnings[0]
+ assert warning["source"] == "import:opengraph_empty_id_skipped"
+ assert "skipped 1 node(s) with empty id" in warning["warning"]
+
+
+def test_opengraph_empty_id_node_with_malformed_kinds_rejected_before_resolution():
+ conn = RecordingConnection()
+ importer = BloodhoundImporter(conn)
+ data = _opengraph(
+ "Mixed_Base",
+ [
+ {
+ "id": "S-1-5-21-999-1110",
+ "kinds": ["Computer", "Base"],
+ "properties": {"name": "SQL01.LAB.LOCAL", "DNSHostName": "sql01.lab.local"},
+ },
+ {
+ "id": "",
+ "kinds": "Bad Label",
+ "properties": {"DNSHostName": "sql01.lab.local"},
+ },
+ ],
+ )
+
+ with pytest.raises(ValueError, match="kinds must be a list"):
+ importer._validate_opengraph_payloads([("computers.json", data, "opengraph")])
+
+
+def test_dedupe_mssql_servers_merges_duplicates(capsys):
+ conn = RecordingConnection(
+ responses=[
+ [
+ {
+ "canonical": "sccmdb:1433",
+ "stale": ["S-1-5-21-1105:1433", "SCCMDB.training.local:1433"],
+ }
+ ],
+ [],
+ [{"rtype": "MSSQL_LinkedAsAdmin"}],
+ [],
+ [],
+ [],
+ [],
+ [{"rtype": "MSSQL_Contains"}],
+ [],
+ [],
+ [],
+ ]
+ )
+ importer = BloodhoundImporter(conn)
+
+ importer._dedupe_mssql_servers()
+
+ output = capsys.readouterr().out
+ assert "Merged 2 duplicate MSSQL_Server" in output
+ queries = [q[0] for q in conn.queries]
+ assert any("toLower(s.name)" in q for q in queries)
+ assert any("SET c += sp SET c += cp" in q for q in queries)
+ assert any("DETACH DELETE" in q for q in queries)
+
+
+def test_dedupe_mssql_servers_no_op_when_unique(capsys):
+ conn = RecordingConnection(responses=[[]])
+ importer = BloodhoundImporter(conn)
+
+ importer._dedupe_mssql_servers()
+
+ output = capsys.readouterr().out
+ assert "Merged" not in output
+ assert len(conn.queries) == 1
+
+
+def test_dedupe_mssql_servers_property_merge_lets_canonical_win_on_conflict():
+ conn = RecordingConnection(
+ responses=[
+ [{"canonical": "real:1433", "stale": ["stub:1433"]}],
+ [],
+ [],
+ [],
+ ]
+ )
+ importer = BloodhoundImporter(conn)
+
+ importer._dedupe_mssql_servers()
+
+ node_merge_query = next(q for q, _ in conn.queries if "SET c +=" in q)
+ assert "SET c += sp SET c += cp" in node_merge_query
+ assert node_merge_query.index("SET c += sp") < node_merge_query.index("SET c += cp")
+
+
+def test_dedupe_mssql_servers_relationship_merge_preserves_existing_properties_and_skips_self_loops():
+ conn = RecordingConnection(
+ responses=[
+ [{"canonical": "real:1433", "stale": ["stub:1433"]}],
+ [],
+ [{"rtype": "MSSQL_Contains"}],
+ [],
+ [],
+ [],
+ ]
+ )
+ importer = BloodhoundImporter(conn)
+
+ importer._dedupe_mssql_servers()
+
+ rel_merge_queries = [q for q, _ in conn.queries if "MERGE" in q and "-[new:`MSSQL_Contains`]" in q]
+ assert len(rel_merge_queries) == 2
+ assert "WHERE t <> c" in rel_merge_queries[0]
+ assert "WHERE src <> c" in rel_merge_queries[1]
+ for query in rel_merge_queries:
+ assert "SET new += old_props SET new += existing_props" in query
+ assert query.index("SET new += old_props") < query.index("SET new += existing_props")
+
+
+def test_dedupe_mssql_servers_groups_by_full_name_not_short_hostname():
+ conn = RecordingConnection(responses=[[]])
+ importer = BloodhoundImporter(conn)
+
+ importer._dedupe_mssql_servers()
+
+ groups_query = conn.queries[0][0]
+ assert "toLower(s.name)" in groups_query
+ assert "split(s.name" not in groups_query
+
+
+def test_canonicalize_mssql_linked_server_edges_uses_sid_prefix(capsys):
+ conn = RecordingConnection(responses=[[{"merged": 1}], [{"merged": 2}]])
+ importer = BloodhoundImporter(conn)
+
+ merged = importer._canonicalize_mssql_linked_server_edges()
+
+ output = capsys.readouterr().out
+ assert merged == 3
+ assert "Merged 3 canonical MSSQL linked-server edge" in output
+ queries = [q for q, _ in conn.queries]
+ assert any("MSSQL_LinkedTo" in q for q in queries)
+ assert any("MSSQL_LinkedAsAdmin" in q for q in queries)
+ assert all("split(stub.objectid, ':')[0] AS sourceSid" in q for q in queries)
+ assert all("split(source.objectid, ':')[0] = sourceSid" in q for q in queries)
+ assert all("MERGE (source)-[new:" in q for q in queries)
+ assert all("SET new += old_props SET new += existing_props" in q for q in queries)
+ assert all("NOT stub:MSSQL_Server" in q for q in queries)
+ assert all("count(DISTINCT new)" in q for q in queries)
diff --git a/tests/test_importer_zip_slip.py b/tests/test_importer_zip_slip.py
new file mode 100644
index 0000000..f410f79
--- /dev/null
+++ b/tests/test_importer_zip_slip.py
@@ -0,0 +1,62 @@
+import json
+import zipfile
+from pathlib import Path
+
+import pytest
+
+from modules.BloodhoundImporter import BloodhoundImporter
+
+
+def _write_zip(zip_path: Path, entries: dict) -> Path:
+ with zipfile.ZipFile(zip_path, 'w') as zip_ref:
+ for name, content in entries.items():
+ zip_ref.writestr(name, content)
+ return zip_path
+
+
+def test_extract_zip_rejects_parent_directory_traversal(tmp_path):
+ zip_path = _write_zip(tmp_path / "evil.zip", {
+ "../escapee.json": json.dumps({"k": "v"}),
+ })
+ extract_dir = tmp_path / "extract"
+ extract_dir.mkdir()
+
+ importer = BloodhoundImporter(None)
+
+ with pytest.raises(Exception, match="escapes extract directory"):
+ importer._extract_zip(zip_path, extract_dir)
+
+ assert not (tmp_path / "escapee.json").exists()
+
+
+def test_extract_zip_rejects_absolute_path_member(tmp_path):
+ zip_path = tmp_path / "evil.zip"
+ with zipfile.ZipFile(zip_path, 'w') as zip_ref:
+ info = zipfile.ZipInfo(filename="/tmp/adpf_zipslip_canary.json")
+ zip_ref.writestr(info, json.dumps({"k": "v"}))
+ extract_dir = tmp_path / "extract"
+ extract_dir.mkdir()
+
+ importer = BloodhoundImporter(None)
+
+ with pytest.raises(Exception, match="escapes extract directory"):
+ importer._extract_zip(zip_path, extract_dir)
+
+ assert not Path("/tmp/adpf_zipslip_canary.json").exists()
+
+
+def test_extract_zip_accepts_well_formed_archive(tmp_path):
+ zip_path = _write_zip(tmp_path / "good.zip", {
+ "users.json": json.dumps({"users": []}),
+ "nested/groups.json": json.dumps({"groups": []}),
+ })
+ extract_dir = tmp_path / "extract"
+ extract_dir.mkdir()
+
+ importer = BloodhoundImporter(None)
+
+ json_files = importer._extract_zip(zip_path, extract_dir)
+
+ assert sorted(p.name for p in json_files) == ["groups.json", "users.json"]
+ assert (extract_dir / "users.json").exists()
+ assert (extract_dir / "nested" / "groups.json").exists()
diff --git a/tests/test_manager_hardening.py b/tests/test_manager_hardening.py
index b8d1f4a..176bf80 100644
--- a/tests/test_manager_hardening.py
+++ b/tests/test_manager_hardening.py
@@ -182,6 +182,11 @@ def test_check_detail_and_platform_summary_per_domain(self):
@check(risk="High", category="MSSQL Test", data=[])
class MSSQLTestCheck(Check):
def execute(self):
+ diagnostics.record_query(
+ name=f"mssql_test_{self.neo4j_data._domain_filter}",
+ duration_ms=25,
+ result_count=1,
+ )
return {self.neo4j_data._domain_filter: "finding"}
diagnostics = DiagnosticsCollector()
@@ -199,8 +204,45 @@ def execute(self):
)
self.assertEqual(diagnostics.mssql_sccm["training.local"]["mssql"]["checks_run"], 1)
self.assertEqual(diagnostics.mssql_sccm["training.local"]["mssql"]["total_findings"], 1)
+ self.assertEqual(diagnostics.mssql_sccm["training.local"]["mssql"]["queries_run"], 1)
+ self.assertEqual(diagnostics.mssql_sccm["training.local"]["mssql"]["query_duration_ms"], 25)
+ self.assertEqual(
+ diagnostics.mssql_sccm["training.local"]["mssql"]["slowest_check"]["name"],
+ "MSSQLTestCheck",
+ )
self.assertEqual(diagnostics.mssql_sccm["secret.training.local"]["mssql"]["checks_run"], 1)
self.assertEqual(diagnostics.mssql_sccm["secret.training.local"]["mssql"]["total_findings"], 1)
+ self.assertEqual(diagnostics.mssql_sccm["secret.training.local"]["mssql"]["queries_run"], 1)
+
+ def test_platform_summary_groups_sccm_takeover_query_names(self):
+ diagnostics = DiagnosticsCollector()
+ diagnostics.record_check(
+ name="SCCMTestCheck",
+ risk_level="Critical",
+ category="SCCM Test",
+ entity_type="computer",
+ duration_ms=50,
+ entities_input=0,
+ entities_after_filter=0,
+ filtered_breakdown={},
+ findings_count=1,
+ domain="training.local",
+ )
+ diagnostics.record_query(
+ name="takeover1_edge_paths",
+ duration_ms=42,
+ result_count=1,
+ )
+ manager = VulnerabilityFrameworkManager(
+ FakeDomainNeo4jData("training.local"),
+ diagnostics=diagnostics,
+ )
+ manager._diagnostic_query_start_index = 0
+ manager._record_platform_summary()
+ sccm_summary = diagnostics.mssql_sccm["training.local"]["sccm"]
+
+ self.assertEqual(sccm_summary["queries_run"], 1)
+ self.assertEqual(sccm_summary["slowest_query"]["name"], "takeover1_edge_paths")
def test_record_diagnostics_domains_keeps_all_discovered_domains(self):
diagnostics = DiagnosticsCollector()
diff --git a/tests/test_neo4j_fixture_config.py b/tests/test_neo4j_fixture_config.py
new file mode 100644
index 0000000..6732b57
--- /dev/null
+++ b/tests/test_neo4j_fixture_config.py
@@ -0,0 +1,25 @@
+import importlib.util
+from pathlib import Path
+
+
+def _load_test_conftest():
+ path = Path(__file__).with_name("conftest.py")
+ spec = importlib.util.spec_from_file_location("adpf_test_conftest", path)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+conftest = _load_test_conftest()
+
+
+def test_neo4j_fixture_defaults_to_disposable_test_container():
+ assert conftest.DEFAULT_NEO4J_TEST_URI == "bolt://localhost:17687"
+ assert conftest.DEFAULT_NEO4J_TEST_PASSWORD == "testpassword"
+
+
+def test_bloodhound_default_port_guard_detects_common_local_uris():
+ assert conftest._bloodhound_default_port("neo4j://localhost:7687")
+ assert conftest._bloodhound_default_port("bolt://127.0.0.1")
+ assert not conftest._bloodhound_default_port(conftest.DEFAULT_NEO4J_TEST_URI)
+ assert not conftest._bloodhound_default_port("bolt://localhost:17687")
diff --git a/tests/test_platform_privilege_filtering.py b/tests/test_platform_privilege_filtering.py
new file mode 100644
index 0000000..7dfe11a
--- /dev/null
+++ b/tests/test_platform_privilege_filtering.py
@@ -0,0 +1,311 @@
+from checks.core.dependencies import CheckDependencies
+from checks.mssql_ntlm_relay import MSSQLNTLMRelayCheck
+from checks.mssql_privilege_escalation import MSSQLPrivilegeEscalationCheck
+from checks.sccm_privilege_escalation import SCCMPrivilegeEscalationCheck
+
+
+class FakeNeo4jData:
+ high_value_users_cache = ["PKIADMIN@TRAINING.LOCAL"]
+ high_value_computers_cache = []
+
+ def __init__(self):
+ self.conn = self
+
+ def get_admin_users_and_computers(self):
+ return ["DA@TRAINING.LOCAL"], []
+
+ def query(self, _cypher, parameters=None, name=None):
+ return []
+
+
+def _deps():
+ return CheckDependencies(FakeNeo4jData())
+
+
+def test_mssql_privilege_escalation_skips_high_value_start_principals():
+ class StubMSSQLCheck(MSSQLPrivilegeEscalationCheck):
+ def _fetch_escalation_paths(self):
+ path = {
+ 'login_name': 'TRAINING\\lowpriv',
+ 'target_name': 'sysadmin',
+ 'target_type': 'MSSQL_ServerRole',
+ 'target_server': 'sql01.training.local:1433',
+ 'path_nodes': ['TRAINING\\lowpriv', 'sysadmin'],
+ 'path_edges': ['MSSQL_MemberOf'],
+ }
+ return {
+ 'S-1-5-21-TRAINING-500': {
+ 'name': 'DA@TRAINING.LOCAL',
+ 'principal_type': 'User',
+ 'paths': [path],
+ },
+ 'S-1-5-21-TRAINING-1101': {
+ 'name': 'LOWPRIV@TRAINING.LOCAL',
+ 'principal_type': 'User',
+ 'paths': [path],
+ },
+ }
+
+ findings = StubMSSQLCheck(_deps()).execute()
+
+ assert 'S-1-5-21-TRAINING-500' not in findings
+ assert 'S-1-5-21-TRAINING-1101' in findings
+
+
+def test_sccm_privilege_escalation_skips_high_value_service_accounts():
+ class StubSCCMCheck(SCCMPrivilegeEscalationCheck):
+ def _fetch_sccm_paths(self):
+ base_path = {
+ 'attack_type': 'service_account',
+ 'target_name': 'SQL Server Service Account',
+ 'target_type': 'MSSQL_Server',
+ 'database_name': 'CM_TRN',
+ 'server_name': 'sccmdb.training.local:1433',
+ 'path_edges': ['MSSQL_ServiceAccountFor'],
+ 'path_length': 1,
+ 'sccm_impact_db': 'CM_TRN',
+ 'sccm_impact_site': 'TRN',
+ }
+ return [
+ {
+ **base_path,
+ 'principal_sid': 'S-1-5-21-TRAINING-500',
+ 'principal_name': 'DA@TRAINING.LOCAL',
+ 'principal_type': 'User',
+ },
+ {
+ **base_path,
+ 'principal_sid': 'S-1-5-21-TRAINING-1101',
+ 'principal_name': 'LOWPRIV@TRAINING.LOCAL',
+ 'principal_type': 'User',
+ },
+ ]
+
+ findings = StubSCCMCheck(_deps()).execute()
+
+ assert 'S-1-5-21-TRAINING-500' not in findings
+ assert 'S-1-5-21-TRAINING-1101' in findings
+
+
+def test_mssql_privilege_escalation_skips_graph_tagged_high_value_principals():
+ class StubMSSQLCheck(MSSQLPrivilegeEscalationCheck):
+ def query(self, _cypher, parameters=None, name=None):
+ if name == "mssql_privilege_escalation_high_value_principals":
+ return [{"name": "TIER0USER@TRAINING.LOCAL", "type": "User"}]
+ return []
+
+ def _fetch_escalation_paths(self):
+ path = {
+ 'login_name': 'TRAINING\\tier0user',
+ 'target_name': 'sysadmin',
+ 'target_type': 'MSSQL_ServerRole',
+ 'target_server': 'sql01.training.local:1433',
+ 'path_nodes': ['TRAINING\\tier0user', 'sysadmin'],
+ 'path_edges': ['MSSQL_MemberOf'],
+ }
+ return {
+ 'S-1-5-21-TRAINING-700': {
+ 'name': 'TIER0USER@TRAINING.LOCAL',
+ 'principal_type': 'User',
+ 'paths': [path],
+ },
+ 'S-1-5-21-TRAINING-1101': {
+ 'name': 'LOWPRIV@TRAINING.LOCAL',
+ 'principal_type': 'User',
+ 'paths': [path],
+ },
+ }
+
+ findings = StubMSSQLCheck(_deps()).execute()
+
+ assert 'S-1-5-21-TRAINING-700' not in findings
+ assert 'S-1-5-21-TRAINING-1101' in findings
+
+
+def test_sccm_privilege_escalation_skips_graph_tagged_high_value_principals():
+ class StubSCCMCheck(SCCMPrivilegeEscalationCheck):
+ def query(self, _cypher, parameters=None, name=None):
+ if name == "sccm_privilege_escalation_high_value_principals":
+ return [{"name": "TIER0USER@TRAINING.LOCAL", "type": "User"}]
+ return []
+
+ def _fetch_sccm_paths(self):
+ base_path = {
+ 'attack_type': 'service_account',
+ 'target_name': 'SQL Server Service Account',
+ 'target_type': 'MSSQL_Server',
+ 'database_name': 'CM_TRN',
+ 'server_name': 'sccmdb.training.local:1433',
+ 'path_edges': ['MSSQL_ServiceAccountFor'],
+ 'path_length': 1,
+ 'sccm_impact_db': 'CM_TRN',
+ 'sccm_impact_site': 'TRN',
+ }
+ return [
+ {
+ **base_path,
+ 'principal_sid': 'S-1-5-21-TRAINING-700',
+ 'principal_name': 'TIER0USER@TRAINING.LOCAL',
+ 'principal_type': 'User',
+ },
+ {
+ **base_path,
+ 'principal_sid': 'S-1-5-21-TRAINING-1101',
+ 'principal_name': 'LOWPRIV@TRAINING.LOCAL',
+ 'principal_type': 'User',
+ },
+ ]
+
+ findings = StubSCCMCheck(_deps()).execute()
+
+ assert 'S-1-5-21-TRAINING-700' not in findings
+ assert 'S-1-5-21-TRAINING-1101' in findings
+
+
+def test_sccm_privilege_escalation_formats_raw_sql_to_site_scope():
+ check = SCCMPrivilegeEscalationCheck(_deps())
+ line = check._format_path_line({
+ 'attack_type': 'privilege_escalation',
+ 'target_type': 'MSSQL_ServerRole',
+ 'server_name': 'sccmdb.training.local:1433',
+ 'path_edges': ['MSSQL_HasLogin', 'MSSQL_MemberOf'],
+ 'path_node_names': ['LOWPRIV@TRAINING.LOCAL', 'TRAINING\\lowpriv', 'sysadmin'],
+ 'sccm_impact_db': 'CM_TRN',
+ 'sccm_impact_site': 'TRN',
+ })
+
+ assert line == (
+ "LOWPRIV -> MSSQL_HasLogin -> lowpriv -> MSSQL_MemberOf "
+ "-> sysadmin | MSSQL_Database(CM_TRN) -> SCCM_AssignAllPermissions -> SCCM_Site(TRN)"
+ )
+ assert "can enable xp_cmdshell" not in line
+
+
+def test_sccm_privilege_escalation_enriches_execute_on_host_context():
+ check = SCCMPrivilegeEscalationCheck(_deps())
+ line = check._format_path_line({
+ 'attack_type': 'privilege_escalation',
+ 'target_type': 'SCCM_Site',
+ 'path_edges': ['MSSQL_ExecuteOnHost', 'SCCM_AssignAllPermissions'],
+ 'path_edge_contexts': ['as SQLSVC@TRAINING.LOCAL', None],
+ 'path_node_names': [
+ 'sccmdb.training.local:1433',
+ 'SCCMDB.TRAINING.LOCAL',
+ 'SCCM_Site(TRN)',
+ ],
+ 'sccm_impact_site': 'TRN',
+ })
+
+ assert line == (
+ "sccmdb.training.local -> MSSQL_ExecuteOnHost (as SQLSVC@TRAINING.LOCAL) "
+ "-> SCCMDB.TRAINING.LOCAL -> SCCM_AssignAllPermissions -> SCCM_Site(TRN)"
+ )
+
+
+def test_sccm_service_account_query_scopes_ad_accounts_to_domain():
+ check = SCCMPrivilegeEscalationCheck.__new__(SCCMPrivilegeEscalationCheck)
+ check._domain_filter = "training.local"
+ captured = {}
+
+ def capture_query(cypher, parameters=None, name=None):
+ captured["cypher"] = cypher
+ return []
+
+ check.query = capture_query
+
+ assert check._fetch_service_account_paths() == []
+ assert 'serviceAccount.domain IN ["TRAINING.LOCAL", ["TRAINING.LOCAL"]]' in captured["cypher"]
+ assert "OR NOT (serviceAccount:User OR serviceAccount:Computer OR serviceAccount:Group)" in captured["cypher"]
+
+
+def test_sccm_coercion_prefers_matching_ad_source_only():
+ check = SCCMPrivilegeEscalationCheck.__new__(SCCMPrivilegeEscalationCheck)
+ paths = [
+ {
+ 'principal_type': 'User',
+ 'principal_name': 'DA@TRAINING.LOCAL',
+ 'server_name': 'sql01.training.local:1433',
+ 'sccm_impact_site': 'TRN',
+ },
+ {
+ 'principal_type': 'MSSQL',
+ 'principal_name': 'TRAINING\\DA',
+ 'server_name': 'sql01.training.local',
+ 'sccm_impact_site': 'TRN',
+ },
+ {
+ 'principal_type': 'MSSQL',
+ 'principal_name': 'mssql-stub',
+ 'server_name': 'sql01.training.local',
+ 'sccm_impact_site': 'TRN',
+ },
+ ]
+
+ kept = check._prefer_ad_coercion_sources(paths)
+
+ assert [path['principal_name'] for path in kept] == ['DA@TRAINING.LOCAL', 'mssql-stub']
+
+
+def test_sccm_format_principal_keeps_distinct_service_account_servers():
+ check = SCCMPrivilegeEscalationCheck.__new__(SCCMPrivilegeEscalationCheck)
+ base = {
+ 'attack_type': 'service_account',
+ 'principal_name': 'SQLSVC@TRAINING.LOCAL',
+ 'principal_type': 'User',
+ 'target_type': 'MSSQL_Server',
+ 'path_edges': ['MSSQL_ServiceAccountFor'],
+ 'path_length': 1,
+ 'sccm_impact_db': 'CM_TRN',
+ 'sccm_impact_site': 'TRN',
+ }
+
+ lines = check._format_principal([
+ {**base, 'server_name': 'sql01.training.local:1433'},
+ {**base, 'server_name': 'sql02.training.local:1433'},
+ ])
+
+ assert len(lines) == 2
+ assert any("sql01.training.local" in line for line in lines)
+ assert any("sql02.training.local" in line for line in lines)
+
+
+def test_mssql_ntlm_relay_dedupes_equivalent_principal_formats():
+ check = MSSQLNTLMRelayCheck.__new__(MSSQLNTLMRelayCheck)
+ check._domain_filter = "training.local"
+
+ principals = check._dedupe([
+ "TRAINING\\DA",
+ "DA@TRAINING.LOCAL",
+ "OTHER\\DA",
+ ])
+
+ assert principals == ["DA@TRAINING.LOCAL", "OTHER\\DA"]
+
+
+def test_mssql_ntlm_relay_does_not_collide_across_domains_sharing_netbios_prefix():
+ check = MSSQLNTLMRelayCheck.__new__(MSSQLNTLMRelayCheck)
+ check._domain_filter = "corp.local"
+
+ principals = check._dedupe([
+ "DA@CORP.LOCAL",
+ "DA@CORP.EXAMPLE.COM",
+ ])
+
+ assert principals == ["DA@CORP.LOCAL", "DA@CORP.EXAMPLE.COM"]
+
+
+def test_mssql_ntlm_relay_caps_principal_lines_per_server():
+ check = MSSQLNTLMRelayCheck.__new__(MSSQLNTLMRelayCheck)
+
+ principals = [f"USER{i}@TRAINING.LOCAL" for i in range(12)]
+ desc = check._format_relay_path(
+ principals,
+ "SQL01.TRAINING.LOCAL",
+ "SQLSVC@TRAINING.LOCAL",
+ ["WEB01 (Windows Server)"],
+ )
+
+ lines = desc.split("\n")
+ assert sum(1 for line in lines if "xp_dirtree" in line) == 5
+ assert lines[-1] == "(+7 more principals)"
+ assert "USER5@TRAINING.LOCAL" not in desc
diff --git a/tests/test_report_label_filtering.py b/tests/test_report_label_filtering.py
index d2b67b2..b0fb507 100644
--- a/tests/test_report_label_filtering.py
+++ b/tests/test_report_label_filtering.py
@@ -1,7 +1,7 @@
from modules.neo4j_data import Neo4jData
from checks.core.dependencies import CheckDependencies
from checks.mssql_privilege_escalation import MSSQLPrivilegeEscalationCheck
-from checks.mssql_sccm import MSSQLSCCMCheck
+from checks.sccm_privilege_escalation import SCCMPrivilegeEscalationCheck
class RecordingQueryConnection:
@@ -57,10 +57,10 @@ def test_sid_object_lookup_suppresses_plugin_only_type_in_ad_report_context():
assert "metadata_labels" in parameters
-def test_mssql_sccm_check_canonicalises_broad_platform_labels():
- check, conn = _build_check(MSSQLSCCMCheck)
+def test_sccm_privilege_check_canonicalises_broad_platform_labels():
+ check, conn = _build_check(SCCMPrivilegeEscalationCheck)
- check._fetch_priv_esc_paths()
+ check._fetch_sql_to_sccm_paths()
check._fetch_coercion_paths()
check._fetch_service_account_paths()
diff --git a/tests/test_shared_graph_paths_display.py b/tests/test_shared_graph_paths_display.py
new file mode 100644
index 0000000..af017eb
--- /dev/null
+++ b/tests/test_shared_graph_paths_display.py
@@ -0,0 +1,80 @@
+from checks.core.display.shared_graph_paths import SharedGraphPathDisplayHandler
+
+
+class FakeSidMapper:
+ def __init__(self, mapping):
+ self.mapping = mapping
+
+ def get_display_name(self, sid):
+ return self.mapping.get(sid, sid)
+
+
+class FakeCheck:
+ ENTITY_TYPE = 'user'
+
+
+def _run(results):
+ handler = SharedGraphPathDisplayHandler(
+ suppress_terminal_output=True,
+ check_instance=FakeCheck(),
+ sid_mapper=FakeSidMapper({
+ 'S-1-1': 'USER1',
+ 'S-1-2': 'USER2',
+ }),
+ )
+ content = []
+ handler.display(results, '', 'SCCM Privilege Escalation', content, len(results), '')
+ return content
+
+
+def test_graph_path_display_shows_only_node_chains():
+ suffix = (
+ "MSSQL_Connect -> lab-sql01.training.local -> MSSQL_LinkedAsAdmin "
+ "-> sccmdb.training.local -> MSSQL_Contains -> sysadmin@sccmdb.training.local"
+ )
+ out = _run({
+ 'S-1-1': f"user1 -> {suffix}",
+ 'S-1-2': f"user2 -> {suffix}",
+ })
+
+ assert " ▶ USER1" in out
+ assert " ▶ USER2" in out
+ assert (
+ " user1 > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin "
+ "> sccmdb.training.local > MSSQL_Contains > sysadmin@sccmdb.training.local"
+ ) in out
+ assert (
+ " user2 > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin "
+ "> sccmdb.training.local > MSSQL_Contains > sysadmin@sccmdb.training.local"
+ ) in out
+ assert not any("Shared Graph Path" in line for line in out)
+ assert not any("Path:" in line for line in out)
+
+
+def test_shared_graph_path_splits_sccm_scope_from_path_template():
+ path = (
+ "user1 -> MSSQL_Connect -> lab-sql01.training.local -> MSSQL_LinkedAsAdmin "
+ "-> sccmdb.training.local -> MSSQL_Contains -> MSSQL_Database(CM_TRN) "
+ "-> MSSQL_Contains -> db_owner@CM_TRN"
+ )
+ scope = "MSSQL_Database(CM_TRN) -> SCCM_AssignAllPermissions -> SCCM_Site(TRN)"
+
+ out = _run({'S-1-1': f"{path} | {scope}"})
+
+ assert (
+ " user1 > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin "
+ "> sccmdb.training.local > MSSQL_Contains > MSSQL_Database(CM_TRN) "
+ "> MSSQL_Contains > db_owner@CM_TRN > SCCM_Site(TRN)"
+ ) in out
+ assert not any("SCCM Scope:" in line for line in out)
+
+
+def test_scope_node_with_platform_prefix_is_not_dropped():
+ out = _run({
+ 'S-1-1': (
+ "user1 -> MSSQL_Connect -> sql01 | "
+ "SCCM_AssignAllPermissions -> MSSQL_login_stub"
+ )
+ })
+
+ assert " user1 > MSSQL_Connect > sql01 > MSSQL_login_stub" in out
diff --git a/tests/test_suppress_subordinate_sql_roles.py b/tests/test_suppress_subordinate_sql_roles.py
new file mode 100644
index 0000000..6251481
--- /dev/null
+++ b/tests/test_suppress_subordinate_sql_roles.py
@@ -0,0 +1,111 @@
+from checks.core.dependencies import CheckDependencies
+from checks.sccm_privilege_escalation import SCCMPrivilegeEscalationCheck
+
+
+class FakeNeo4jData:
+ high_value_users_cache = []
+ high_value_computers_cache = []
+
+ def __init__(self):
+ self.conn = self
+
+ def get_admin_users_and_computers(self):
+ return [], []
+
+ def query(self, _cypher, parameters=None, name=None):
+ return []
+
+
+def _check():
+ return SCCMPrivilegeEscalationCheck(CheckDependencies(FakeNeo4jData()))
+
+
+def _server_role(name, server='sql01.training.local:1433', site=''):
+ return {
+ 'target_type': 'MSSQL_ServerRole',
+ 'target_name': name,
+ 'server_name': server,
+ 'database_name': '',
+ 'sccm_impact_site': site,
+ }
+
+
+def _db_role(name, server='sql01.training.local:1433', database='master'):
+ return {
+ 'target_type': 'MSSQL_DatabaseRole',
+ 'target_name': name,
+ 'server_name': server,
+ 'database_name': database,
+ 'sccm_impact_site': '',
+ }
+
+
+def test_sysadmin_suppresses_subordinate_server_role_in_same_scope():
+ paths = [_server_role('sysadmin'), _server_role('securityadmin')]
+
+ filtered = _check()._suppress_subordinate_sql_roles(paths)
+
+ assert [p['target_name'] for p in filtered] == ['sysadmin']
+
+
+def test_sysadmin_absent_keeps_subordinate_server_role():
+ paths = [_server_role('securityadmin'), _server_role('serveradmin')]
+
+ filtered = _check()._suppress_subordinate_sql_roles(paths)
+
+ assert [p['target_name'] for p in filtered] == ['securityadmin', 'serveradmin']
+
+
+def test_db_owner_suppresses_subordinate_database_role_in_same_scope():
+ paths = [_db_role('db_owner'), _db_role('db_securityadmin')]
+
+ filtered = _check()._suppress_subordinate_sql_roles(paths)
+
+ assert [p['target_name'] for p in filtered] == ['db_owner']
+
+
+def test_sysadmin_on_one_server_does_not_suppress_subordinate_on_another():
+ paths = [
+ _server_role('sysadmin', server='sql01.training.local:1433'),
+ _server_role('securityadmin', server='sql02.training.local:1433'),
+ ]
+
+ filtered = _check()._suppress_subordinate_sql_roles(paths)
+
+ assert [(p['target_name'], p['server_name']) for p in filtered] == [
+ ('sysadmin', 'sql01.training.local:1433'),
+ ('securityadmin', 'sql02.training.local:1433'),
+ ]
+
+
+def test_non_subordinate_role_is_kept_even_when_sysadmin_present():
+ paths = [_server_role('sysadmin'), _server_role('processadmin')]
+
+ filtered = _check()._suppress_subordinate_sql_roles(paths)
+
+ assert [p['target_name'] for p in filtered] == ['sysadmin', 'processadmin']
+
+
+def test_role_key_strips_scope_suffix_and_normalises_case():
+ paths = [
+ _server_role('SYSADMIN@SQL01'),
+ _server_role('SecurityAdmin@SQL01'),
+ ]
+
+ filtered = _check()._suppress_subordinate_sql_roles(paths)
+
+ assert [p['target_name'] for p in filtered] == ['SYSADMIN@SQL01']
+
+
+def test_sccm_impact_site_isolates_suppression_scope():
+ paths = [
+ _server_role('sysadmin', site='PRI'),
+ _server_role('securityadmin', site='SEC'),
+ ]
+
+ filtered = _check()._suppress_subordinate_sql_roles(paths)
+
+ assert [(p['target_name'], p['sccm_impact_site']) for p in filtered] == [
+ ('sysadmin', 'PRI'),
+ ('securityadmin', 'SEC'),
+ ]