Skip to content

Commit d695f81

Browse files
author
r0BIT
committed
feat: add gMSA detection hint for LSA secrets (F1)
When a task runs as a gMSA account (username ends with $), display hint: 'gMSA credentials are stored in LSA secrets, not DPAPI. Consider LSA dump if you have SYSTEM access.' - Added _check_gmsa_account() helper to detect gMSA accounts - Skips well-known system accounts (NT AUTHORITY, LOCAL SERVICE, etc.) - Added 5 unit tests for gMSA detection logic
1 parent 9b60069 commit d695f81

File tree

2 files changed

+148
-1
lines changed

2 files changed

+148
-1
lines changed

taskhound/output/printer.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,54 @@ def format_trigger_info(meta: Dict[str, str]) -> Optional[str]:
114114
return " ".join(trigger_parts) if len(trigger_parts) > 1 else trigger_type
115115

116116

117+
def _check_gmsa_account(display_runas: str, resolved_username: Optional[str] = None) -> Optional[str]:
118+
"""
119+
Check if the runas account is a gMSA (Group Managed Service Account).
120+
121+
gMSA accounts:
122+
- End with '$' character
123+
- Are NOT machine/computer accounts (typically match computer name)
124+
- Are NOT well-known system accounts (NT AUTHORITY, etc.)
125+
126+
Args:
127+
display_runas: The display string for the runas account
128+
resolved_username: The resolved username (if SID was resolved)
129+
130+
Returns:
131+
Hint message if gMSA detected, None otherwise
132+
"""
133+
# Get the username to check - prefer resolved, fall back to display
134+
username = resolved_username or display_runas
135+
if not username:
136+
return None
137+
138+
# Extract just the username part (remove domain prefix)
139+
if "\\" in username:
140+
username = username.split("\\")[-1]
141+
elif "@" in username:
142+
username = username.split("@")[0]
143+
144+
# Check if it ends with $ (service or machine account)
145+
if not username.endswith("$"):
146+
return None
147+
148+
# Skip well-known system accounts
149+
well_known_skip = {
150+
"system", "local service", "network service",
151+
"nt authority", "nt service", "iis apppool"
152+
}
153+
username_lower = username.lower()
154+
display_lower = display_runas.lower()
155+
156+
for skip in well_known_skip:
157+
if skip in display_lower:
158+
return None
159+
160+
# At this point we have an account ending with $
161+
# This is likely a gMSA - machine accounts are less common for scheduled tasks
162+
return "gMSA credentials are stored in LSA secrets, not DPAPI. Consider LSA dump if you have SYSTEM access."
163+
164+
117165
def format_block(
118166
kind: str,
119167
rel_path: str,
@@ -358,6 +406,12 @@ def format_block(
358406
if decrypted_password:
359407
base.append(f" Decrypted Password : {decrypted_password}")
360408

409+
# Check if this is a gMSA account and add hint about LSA secrets
410+
# gMSA accounts end with $ but are not machine accounts (COMPUTERNAME$) or well-known system accounts
411+
gmsa_hint = _check_gmsa_account(display_runas, resolved_username)
412+
if gmsa_hint:
413+
base.append(f" gMSA Hint : {gmsa_hint}")
414+
361415
if kind in ["TIER-0", "PRIV"]:
362416
if extra_reason:
363417
base.append(f" Reason : {extra_reason}")

tests/test_printer.py

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -772,4 +772,97 @@ def test_format_block_no_trigger(self, mock_resolve):
772772
)
773773

774774
text = "\n".join(lines)
775-
assert "Trigger :" not in text
775+
assert "Trigger :" not in text
776+
777+
778+
class TestGMSADetection:
779+
"""Test gMSA account detection and hint display."""
780+
781+
@patch("taskhound.output.printer.format_runas_with_sid_resolution")
782+
def test_gmsa_hint_shown_for_gmsa_account(self, mock_resolve):
783+
"""Test that gMSA hint is shown for accounts ending with $."""
784+
mock_resolve.return_value = ("DOMAIN\\gMSAService$", "gMSAService$")
785+
786+
lines = format_block(
787+
kind="TASK",
788+
rel_path="Tasks\\gMSATask",
789+
runas="DOMAIN\\gMSAService$",
790+
what="service.exe",
791+
author="Admin",
792+
date="2023-01-01",
793+
)
794+
795+
text = "\n".join(lines)
796+
assert "gMSA Hint" in text
797+
assert "LSA secrets" in text
798+
assert "not DPAPI" in text
799+
800+
@patch("taskhound.output.printer.format_runas_with_sid_resolution")
801+
def test_gmsa_hint_not_shown_for_regular_account(self, mock_resolve):
802+
"""Test that gMSA hint is NOT shown for regular accounts."""
803+
mock_resolve.return_value = ("DOMAIN\\RegularUser", "RegularUser")
804+
805+
lines = format_block(
806+
kind="TASK",
807+
rel_path="Tasks\\RegularTask",
808+
runas="DOMAIN\\RegularUser",
809+
what="cmd.exe",
810+
author="Admin",
811+
date="2023-01-01",
812+
)
813+
814+
text = "\n".join(lines)
815+
assert "gMSA Hint" not in text
816+
817+
@patch("taskhound.output.printer.format_runas_with_sid_resolution")
818+
def test_gmsa_hint_not_shown_for_system_account(self, mock_resolve):
819+
"""Test that gMSA hint is NOT shown for NT AUTHORITY\\SYSTEM."""
820+
mock_resolve.return_value = ("NT AUTHORITY\\SYSTEM", "SYSTEM")
821+
822+
lines = format_block(
823+
kind="TASK",
824+
rel_path="Tasks\\SystemTask",
825+
runas="NT AUTHORITY\\SYSTEM",
826+
what="system.exe",
827+
author="Admin",
828+
date="2023-01-01",
829+
)
830+
831+
text = "\n".join(lines)
832+
assert "gMSA Hint" not in text
833+
834+
@patch("taskhound.output.printer.format_runas_with_sid_resolution")
835+
def test_gmsa_hint_not_shown_for_local_service(self, mock_resolve):
836+
"""Test that gMSA hint is NOT shown for NT AUTHORITY\\LOCAL SERVICE."""
837+
mock_resolve.return_value = ("NT AUTHORITY\\LOCAL SERVICE", "LOCAL SERVICE")
838+
839+
lines = format_block(
840+
kind="TASK",
841+
rel_path="Tasks\\LocalServiceTask",
842+
runas="NT AUTHORITY\\LOCAL SERVICE",
843+
what="service.exe",
844+
author="Admin",
845+
date="2023-01-01",
846+
)
847+
848+
text = "\n".join(lines)
849+
assert "gMSA Hint" not in text
850+
851+
@patch("taskhound.output.printer.format_runas_with_sid_resolution")
852+
def test_gmsa_hint_shown_with_resolved_username(self, mock_resolve):
853+
"""Test that gMSA hint works when username is resolved from SID."""
854+
mock_resolve.return_value = ("DOMAIN\\SQLService$", "SQLService$")
855+
856+
lines = format_block(
857+
kind="TASK",
858+
rel_path="Tasks\\SQLTask",
859+
runas="S-1-5-21-1234567890-1234567890-1234567890-1234",
860+
what="sqlserver.exe",
861+
author="Admin",
862+
date="2023-01-01",
863+
resolved_runas="DOMAIN\\SQLService$",
864+
)
865+
866+
text = "\n".join(lines)
867+
assert "gMSA Hint" in text
868+
assert "LSA secrets" in text

0 commit comments

Comments
 (0)