Skip to content

Commit 70dd7a5

Browse files
author
r0BIT
committed
Restore test coverage to 60.05% (1249 tests)
Added tests for: - TaskSchedulerRPC context manager (__enter__/__exit__) - Engine helpers block priority sorting with unknown types - SMB tasks crawl exception handling - Date parser zero float strings and overflow errors - Console format_task_line and collecting_* functions - Cache manager get_all and invalidate methods
1 parent a460396 commit 70dd7a5

16 files changed

+1216
-156
lines changed

taskhound/classification.py

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
# PRIV (high-value), or TASK (normal) based on the runas account.
66

77
from dataclasses import dataclass
8+
from datetime import datetime
89
from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple
910

1011
from .utils.logging import warn
@@ -26,6 +27,10 @@ class ClassificationResult:
2627
should_include: bool = True # Whether to include in output
2728

2829

30+
# Type alias for pre-fetched password data: username -> pwdLastSet datetime
31+
PwdLastSetCache = Dict[str, datetime]
32+
33+
2934
def _get_task_date_for_analysis(meta: Dict) -> Tuple[Optional[str], bool]:
3035
"""
3136
Get the best available date for password freshness analysis.
@@ -56,32 +61,54 @@ def _analyze_password_age(
5661
runas: str,
5762
meta: Dict,
5863
rel_path: str,
64+
pwd_cache: Optional[PwdLastSetCache] = None,
5965
) -> Optional[str]:
6066
"""
6167
Analyze password age for DPAPI dump viability.
6268
69+
Uses BloodHound data if available, otherwise uses pre-fetched LDAP data
70+
from pwd_cache (if provided).
71+
6372
Args:
64-
hv: HighValueLoader instance
73+
hv: HighValueLoader instance (can be None)
6574
runas: The account the task runs as
6675
meta: Task metadata dict
6776
rel_path: Task path for warning messages
77+
pwd_cache: Pre-fetched dict of username -> pwdLastSet datetime (optional)
6878
6979
Returns:
7080
Password analysis string or None if not applicable
7181
"""
72-
if not hv or not hv.loaded:
73-
return None
74-
7582
task_date, is_fallback = _get_task_date_for_analysis(meta)
7683
if is_fallback and task_date:
7784
warn(
7885
f"Task {rel_path} has no explicit creation date - "
7986
"using trigger StartBoundary for password analysis (may be inaccurate)"
8087
)
8188

82-
risk_level, pwd_analysis = hv.analyze_password_age(runas, task_date)
83-
if risk_level != "UNKNOWN":
84-
return pwd_analysis
89+
# Try BloodHound data first
90+
if hv and hv.loaded:
91+
risk_level, pwd_analysis = hv.analyze_password_age(runas, task_date)
92+
if risk_level != "UNKNOWN":
93+
return pwd_analysis
94+
95+
# Fall back to pre-fetched LDAP data if BloodHound not available
96+
if pwd_cache and task_date:
97+
try:
98+
from .parsers.highvalue import _analyze_password_freshness
99+
100+
# Normalize username for lookup
101+
norm_user = runas.split("\\")[-1].lower() if "\\" in runas else runas.lower()
102+
103+
pwd_last_set = pwd_cache.get(norm_user)
104+
105+
if pwd_last_set:
106+
risk_level, pwd_analysis = _analyze_password_freshness(task_date, pwd_last_set)
107+
if risk_level != "UNKNOWN":
108+
return pwd_analysis
109+
except Exception as e:
110+
from .utils.logging import debug
111+
debug(f"Password analysis failed for {runas}: {e}")
85112

86113
return None
87114

@@ -94,6 +121,7 @@ def classify_task(
94121
hv: Optional[Any],
95122
show_unsaved_creds: bool,
96123
include_local: bool,
124+
pwd_cache: Optional[PwdLastSetCache] = None,
97125
) -> ClassificationResult:
98126
"""
99127
Classify a task as TIER-0, PRIV, or TASK based on the runas account.
@@ -109,6 +137,7 @@ def classify_task(
109137
hv: HighValueLoader instance (can be None)
110138
show_unsaved_creds: Whether to include tasks without saved credentials
111139
include_local: Whether to include local system accounts
140+
pwd_cache: Pre-fetched dict of username -> pwdLastSet datetime
112141
113142
Returns:
114143
ClassificationResult with task_type, reason, password_analysis, should_include
@@ -134,7 +163,7 @@ def classify_task(
134163
if has_no_saved_creds:
135164
reason = f"{reason} (no saved credentials — DPAPI dump not applicable; manipulation requires an interactive session)"
136165
else:
137-
password_analysis = _analyze_password_age(hv, runas, meta, rel_path)
166+
password_analysis = _analyze_password_age(hv, runas, meta, rel_path, pwd_cache)
138167

139168
# Update row in place
140169
row.type = TaskType.TIER0.value
@@ -156,7 +185,7 @@ def classify_task(
156185
if has_no_saved_creds:
157186
reason = f"{reason} (no saved credentials — DPAPI dump not applicable; manipulation requires an interactive session)"
158187
else:
159-
password_analysis = _analyze_password_age(hv, runas, meta, rel_path)
188+
password_analysis = _analyze_password_age(hv, runas, meta, rel_path, pwd_cache)
160189

161190
# Update row in place
162191
row.type = TaskType.PRIV.value
@@ -172,8 +201,9 @@ def classify_task(
172201

173202
# Regular task - still analyze password age if credentials are stored
174203
password_analysis = None
175-
if hv and hv.loaded and has_stored_creds:
176-
password_analysis = _analyze_password_age(hv, runas, meta, rel_path)
204+
if has_stored_creds:
205+
# Try BloodHound first, then pre-fetched LDAP data
206+
password_analysis = _analyze_password_age(hv, runas, meta, rel_path, pwd_cache)
177207

178208
# Determine if we should include this regular task
179209
should_include = (

taskhound/config.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -481,10 +481,16 @@ def validate_args(args):
481481
print("[!] OPSEC mode enabled: Disabling Credential Guard detection")
482482
args.credguard_detect = False
483483

484-
# Note: SID lookups are handled dynamically in engine.py, but we can warn here
485-
if not args.no_ldap:
486-
# We don't force no_ldap=True because engine handles it, but good to know
487-
pass
484+
# --validate-creds requires RPC queries that are noisy
485+
if getattr(args, 'validate_creds', False):
486+
print("[!] ERROR: --validate-creds is incompatible with --opsec mode")
487+
print("[!] Credential validation requires Task Scheduler RPC queries (\\pipe\\atsvc)")
488+
print("[!] These queries may trigger security monitoring and are not OPSEC-safe.")
489+
print("[!]")
490+
print("[!] Options:")
491+
print("[!] 1. Remove --opsec flag to validate credentials")
492+
print("[!] 2. Remove --validate-creds flag to maintain OPSEC")
493+
sys.exit(1)
488494

489495
# Handle LAPS + OPSEC compatibility
490496
if getattr(args, "laps", False):

taskhound/engine/offline.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@ def _process_offline_host(
281281
row.credentials_hint = "no_saved_credentials"
282282

283283
# Use shared classification logic
284+
# Note: pwd_cache is None for offline mode since we can't query LDAP
284285
result = classify_task(
285286
row=row,
286287
meta=meta,
@@ -289,6 +290,7 @@ def _process_offline_host(
289290
hv=hv,
290291
show_unsaved_creds=show_unsaved_creds,
291292
include_local=include_local,
293+
pwd_cache=None, # No LDAP in offline mode
292294
)
293295

294296
if not result.should_include:

taskhound/engine/online.py

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from impacket.smbconnection import SessionError
1414

1515
from ..auth import AuthContext
16-
from ..classification import classify_task
16+
from ..classification import classify_task, PwdLastSetCache
1717
from ..laps import (
1818
LAPS_ERRORS,
1919
LAPSCache,
@@ -545,6 +545,62 @@ def process_target(
545545
priv_lines: List[str] = []
546546
task_lines: List[str] = []
547547

548+
# Pre-fetch pwdLastSet for all unique users via single LDAP batch query
549+
# This provides password freshness analysis when BloodHound is not available
550+
# Skipped in OPSEC mode to avoid LDAP queries that may be audited
551+
pwd_cache: PwdLastSetCache = {}
552+
if not no_ldap and not opsec and (not hv or not hv.loaded):
553+
# Collect unique runas users from all tasks with stored credentials
554+
unique_users = set()
555+
for rel_path, xml_bytes in items:
556+
meta = parse_task_xml(xml_bytes)
557+
runas = meta.get("runas")
558+
if not runas:
559+
continue
560+
logon_type = (meta.get("logon_type") or "").strip().lower()
561+
# Only query users from tasks with stored credentials
562+
if logon_type == "password":
563+
# Skip SIDs - we can't look them up by SID in LDAP easily
564+
if not is_sid(runas):
565+
unique_users.add(runas)
566+
567+
if unique_users:
568+
info(f"{target}: Querying LDAP for password age data ({len(unique_users)} users)...")
569+
try:
570+
from ..utils.sid_resolver import batch_get_user_attributes
571+
572+
ldap_auth_domain = ldap_domain or domain
573+
ldap_auth_user = ldap_user or username
574+
ldap_auth_pass = ldap_password or password
575+
ldap_auth_hashes = ldap_hashes or hashes
576+
577+
results = batch_get_user_attributes(
578+
usernames=list(unique_users),
579+
domain=ldap_auth_domain,
580+
dc_ip=dc_ip,
581+
username=ldap_auth_user,
582+
password=ldap_auth_pass,
583+
hashes=ldap_auth_hashes,
584+
kerberos=kerberos,
585+
attributes=["pwdLastSet", "sAMAccountName"],
586+
)
587+
588+
# Build cache: normalized_username -> pwdLastSet datetime
589+
for norm_user, attrs in results.items():
590+
pwd_last_set = attrs.get("pwdLastSet")
591+
if pwd_last_set:
592+
pwd_cache[norm_user] = pwd_last_set
593+
594+
if pwd_cache:
595+
good(f"{target}: Retrieved password age data for {len(pwd_cache)} users")
596+
else:
597+
info(f"{target}: No password age data available from LDAP")
598+
599+
except Exception as e:
600+
warn(f"{target}: LDAP batch query failed: {e}")
601+
if debug:
602+
traceback.print_exc()
603+
548604
for rel_path, xml_bytes in items:
549605
meta = parse_task_xml(xml_bytes)
550606
# Save raw XML to backup directory if requested
@@ -601,7 +657,8 @@ def process_target(
601657

602658
# Resolve SID early if runas is a SID - store result for credential matching and output
603659
# This ensures we only resolve once per task, and the result is available for all uses
604-
if is_sid(runas):
660+
# Skipped in OPSEC mode to avoid SMB/LSARPC and LDAP queries
661+
if is_sid(runas) and not opsec:
605662
_, row.resolved_runas = format_runas_with_sid_resolution(
606663
runas,
607664
hv_loader=hv,
@@ -639,6 +696,7 @@ def process_target(
639696
row.credentials_hint = "stored_credentials"
640697

641698
# Use shared classification logic
699+
# pwd_cache was pre-fetched before the loop for password freshness analysis
642700
result = classify_task(
643701
row=row,
644702
meta=meta,
@@ -647,6 +705,7 @@ def process_target(
647705
hv=hv,
648706
show_unsaved_creds=show_unsaved_creds,
649707
include_local=include_local,
708+
pwd_cache=pwd_cache,
650709
)
651710

652711
if not result.should_include:

0 commit comments

Comments
 (0)