-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathAD-Service-Account-Manager.ps1
More file actions
5298 lines (4828 loc) · 260 KB
/
Copy pathAD-Service-Account-Manager.ps1
File metadata and controls
5298 lines (4828 loc) · 260 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#Requires -Modules ActiveDirectory
#Requires -Version 5.1
<#
.SYNOPSIS
AD Service Account Manager v1.0.0
A comprehensive, production-ready tool for managing Active Directory service accounts.
.DESCRIPTION
This script provides a full lifecycle management console for Active Directory service
accounts, including Standard user-based accounts, Managed Service Accounts (MSA), and
Group Managed Service Accounts (gMSA).
Core capabilities:
CREATE - Wizard-driven single account creation, account cloning, and bulk CSV import.
MANAGE - Enable/disable, unlock, password reset, bulk rotation, SPN management,
group assignments, OU moves, account expiry, Recycle Bin restore,
and AD replication verification.
TEST - Health checks, password status, duplicate/conflict SPN detection,
gMSA retrieval tests, MSA host binding validation, Kerberos delegation
review, privileged group scanning, stale/never-logged-on/aging reports,
naming compliance, and bulk OU status.
SECURITY - Kerberoastable account detection, AS-REP Roasting exposure, full delegation
sweep, PASSWD_NOTREQD scan, reversible encryption scan, shadow admin
detection (adminCount=1), SID History scan, weak Kerberos encryption,
logon workstation audits, ACL auditing, AdminSDHolder comparison, Protected
Users impact, and Credential Guard compatibility.
DEPENDENCY- Windows Services, Scheduled Tasks, and IIS Application Pool dependency
mapping — run before any modification or deletion.
VIRTUAL - Discovery and baseline comparison of NT SERVICE\*, SYSTEM, LocalService,
and NetworkService identities on Windows hosts.
AZURE AD - Microsoft Graph-powered Service Principal inventory, credential expiry
alerts, ownership gaps, and high-privilege permission detection.
COMPUTERS - Computer accounts acting as service identities: non-standard SPNs,
delegation configuration, and stale computer service accounts.
GPO - Group Policy impact analysis: effective GPOs, password policy GPOs,
logon-right restrictions, and GPO-managed service credentials.
MULTI-FOREST - Cross-domain and cross-forest inventory, security scanning, and
health auditing; trusted-forest configuration support.
INVENTORY - Full domain discovery (all account types), health audits, and HTML/CSV
export reports.
AUDIT - SHA-256 integrity-protected audit log, SIEM export (JSON/CEF/Syslog),
HTML audit report with compliance cross-references, AD security event
log reading, baseline snapshots, drift detection, and change history.
SETTINGS - SMTP configuration (DPAPI-encrypted passwords), naming conventions,
thresholds, page size, privileged group list, compliance framework,
trusted forests, and SMTP timeout/retry tuning.
Non-interactive modes (schedulable):
Audit, SecurityScan, Inventory, DriftCheck, HealthCheck
Safety features:
-DryRun Preview all write operations without committing any AD changes.
-ReadOnly Disable all write operations for the current session.
-SmtpAlert Send email report after non-interactive runs.
.PARAMETER Mode
Execution mode. Default is Interactive (full menu-driven console).
Non-interactive values: Audit | SecurityScan | Inventory | DriftCheck | HealthCheck
.PARAMETER OutputPath
Override output directory for non-interactive report files.
Defaults to %APPDATA%\ADSvcAcctMgr\reports.
.PARAMETER Domain
Target domain FQDN. Leave empty to auto-detect the current domain.
.PARAMETER Forest
When specified, scope multi-forest operations to the entire forest.
.PARAMETER DryRun
Preview mode. All write operations are logged as WHATIF and not executed.
.PARAMETER ReadOnly
Enforces a read-only session. All create/modify/delete options are blocked.
.PARAMETER SmtpAlert
Triggers an email alert at the end of non-interactive runs.
.EXAMPLE
# Launch the interactive console
.\AD-Service-Account-Manager.ps1
.EXAMPLE
# Run a security scan and email results
.\AD-Service-Account-Manager.ps1 -Mode SecurityScan -SmtpAlert
.EXAMPLE
# Run a full inventory against a specific domain in dry-run mode
.\AD-Service-Account-Manager.ps1 -Mode Inventory -Domain corp.local -DryRun
.EXAMPLE
# Register a weekly drift-detection scheduled task (run interactively first to set SMTP)
.\AD-Service-Account-Manager.ps1 # → Audit → Register scheduled task
.NOTES
Author : (Artur Pchelnikau / SERVTEP)
Created : 2026
Version : 1.0.0
Requires : PowerShell 5.1+, RSAT ActiveDirectory module
Optional : GroupPolicy module (RSAT), Microsoft.Graph (Azure AD features),
WebAdministration module (IIS dependency scan)
IMPORTANT — DPAPI password encryption:
The SMTP password is encrypted with Windows DPAPI, which is tied to the Windows
user account and machine that ran the encryption. The password cannot be decrypted
by a different user or on a different machine. If running as a scheduled task, ensure
the task runs under the same user account that configured SMTP.
SECURITY NOTE:
This script can expose sensitive account information. Restrict access to the script
file and the %APPDATA%\ADSvcAcctMgr directory. Audit logs contain operator names
and action details — protect them accordingly.
#>
param(
# Execution mode — Interactive launches the full menu console
[ValidateSet("Interactive","Audit","SecurityScan","Inventory","DriftCheck","HealthCheck")]
[string]$Mode = "Interactive",
# Optional override for report output directory
[string]$OutputPath = "",
# Target domain FQDN; empty = auto-detect from current environment
[string]$Domain = "",
# Scope multi-forest operations to the entire forest topology
[switch]$Forest,
# Preview all writes without committing; zero AD changes
[switch]$DryRun,
# Block ALL write operations for this session
[switch]$ReadOnly,
# Send email report after non-interactive runs (requires SMTP config)
[switch]$SmtpAlert
)
# Strict mode catches uninitialised variables, bad property access, etc.
Set-StrictMode -Version Latest
# Stop on all terminating errors so callers can catch failures cleanly
$ErrorActionPreference = "Stop"
# ══════════════════════════════════════════════════════════════════════════════
# GLOBALS — Application paths, version, and runtime state
# ══════════════════════════════════════════════════════════════════════════════
# Script version identifier, shown in banners and audit log entries
$SCRIPT_VERSION = "1.0.0"
# Root application data directory — stores config, audit log, reports, history
$APP_DIR = Join-Path $env:APPDATA "ADSvcAcctMgr"
# Append-only CSV audit log; every action is recorded here
$AUDIT_LOG = Join-Path $APP_DIR "audit_log.csv"
# SHA-256 hash of the audit log — used to detect tampering
$AUDIT_HASH = Join-Path $APP_DIR "audit_log.sha256"
# Directory for HTML inventory/audit reports
$REPORT_DIR = Join-Path $APP_DIR "reports"
# JSON snapshot of discovered accounts — used by drift detection
$BASELINE_FILE = Join-Path $APP_DIR "baseline.json"
# Archived baseline snapshots (one per Save-Baseline call)
$HISTORY_DIR = Join-Path $APP_DIR "history"
# Persisted user configuration (SMTP, naming patterns, thresholds, etc.)
$CONFIG_FILE = Join-Path $APP_DIR "config.json"
# Ensure all required directories exist before anything else runs
foreach ($dir in @($APP_DIR, $REPORT_DIR, $HISTORY_DIR)) {
if (-not (Test-Path $dir)) {
New-Item $dir -ItemType Directory -Force | Out-Null
}
}
# ── Default configuration object ──────────────────────────────────────────────
# These values are used when no saved config.json exists, or as fallbacks
# for any property missing from an older saved configuration.
$DEFAULT_CFG = [PSCustomObject]@{
# Regex patterns that identify accounts as service accounts by naming convention.
# Accounts not matching any pattern trigger a naming-violation warning.
NamingPatterns = @("^svc_","^msa_","^gmsa_","^sa_","^svc-","^sa-","^adm_svc","^_svc")
# SMTP settings for alert emails — password stored as DPAPI-encrypted string
SmtpServer = ""
SmtpPort = 587
SmtpFrom = ""
SmtpTo = ""
SmtpUseSsl = $true
SmtpUser = ""
SmtpPassEncrypted = "" # ConvertFrom-SecureString output — DPAPI, user/machine bound
SmtpTimeoutSec = 30 # Per-attempt timeout in seconds
SmtpRetryCount = 2 # Total attempts before giving up
# Accounts with no logon for this many days are flagged as stale
StaleThresholdDays = 90
# Warn when password expires within this many days
PwdWarnDays = 14
# Health audit flags passwords not changed within this many days
PwdMaxAgeDays = 365
# Rows per page in the paged display helper
PageSize = 30
# Max LDAP page size for server-side queries (tune for large domains)
LdapPageSize = 500
# Well-known privileged AD groups scanned for service account membership.
# Membership in these groups is a security finding for service accounts.
AdminGroups = @(
"Domain Admins","Enterprise Admins","Schema Admins","Administrators",
"Account Operators","Backup Operators","Server Operators",
"Print Operators","Group Policy Creator Owners"
)
# Compliance framework label shown in HTML audit reports
ComplianceFramework = "CIS"
# Additional forest FQDNs to include in multi-forest scans
TrustedForests = @()
}
# ── Load or initialise configuration ──────────────────────────────────────────
# If a saved config exists, merge it with defaults so any new keys added in a
# later version of the script are automatically available with sensible values.
$CFG = if (Test-Path $CONFIG_FILE) {
$saved = Get-Content $CONFIG_FILE -Raw | ConvertFrom-Json
# Merge: add any default key that is absent from the saved config
foreach ($prop in $DEFAULT_CFG.PSObject.Properties) {
if (-not ($saved.PSObject.Properties.Name -contains $prop.Name)) {
$saved | Add-Member -NotePropertyName $prop.Name -NotePropertyValue $prop.Value -Force
}
}
$saved
} else {
$DEFAULT_CFG
}
# ── Session-level state flags ──────────────────────────────────────────────────
# $script: scope keeps these accessible from nested functions without passing params
$script:WHATIF = $DryRun.IsPresent # True when running in preview (dry-run) mode
$script:READONLY = $ReadOnly.IsPresent # True when all writes are blocked
$script:ROLE = "Unknown" # Resolved role: DomainAdmin | DelegatedAdmin | ReadOnly
$script:AllDomains = @() # Cached list of discovered forest domains (hashtable[])
# ══════════════════════════════════════════════════════════════════════════════
# CONSOLE HELPERS — Consistent, colour-coded output formatting
# ══════════════════════════════════════════════════════════════════════════════
<#
.SYNOPSIS Prints a major section header with a double-line border.
.PARAMETER T Header text to display.
.PARAMETER C Foreground colour (defaults to Cyan).
#>
function Write-Header {
param([string]$T, [ConsoleColor]$C = 'Cyan')
$line = "═" * 72
Write-Host "`n$line`n $T`n$line" -ForegroundColor $C
}
<#
.SYNOPSIS Prints a sub-section label with a leading dash separator.
#>
function Write-Sub { param([string]$T) Write-Host "`n ── $T" -ForegroundColor DarkCyan }
<#
.SYNOPSIS Prints a step/action indicator (arrow prefix, yellow).
#>
function Write-Step { param([string]$T) Write-Host " ► $T" -ForegroundColor Yellow }
<#
.SYNOPSIS Prints a success confirmation (green tick).
#>
function Write-OK { param([string]$T) Write-Host " ✔ $T" -ForegroundColor Green }
<#
.SYNOPSIS Prints a non-critical warning (yellow triangle).
#>
function Write-Warn { param([string]$T) Write-Host " ⚠ $T" -ForegroundColor DarkYellow }
<#
.SYNOPSIS Prints a failure/error message (red cross).
#>
function Write-Fail { param([string]$T) Write-Host " ✘ $T" -ForegroundColor Red }
<#
.SYNOPSIS Prints an informational note (grey).
#>
function Write-Info { param([string]$T) Write-Host " ℹ $T" -ForegroundColor Gray }
<#
.SYNOPSIS Prints a critical/magenta alert (used for ACL backdoors, integrity failures).
#>
function Write-Crit { param([string]$T) Write-Host " ██ $T" -ForegroundColor Magenta }
# ── Input helpers ─────────────────────────────────────────────────────────────
<#
.SYNOPSIS
Strips characters that are illegal in AD SAM Account Names and enforces the
20-character maximum length mandated by the AD schema.
.DESCRIPTION
The following characters are removed: / \ [ ] : ; | = , + * ? < > @ " °
If the result exceeds 20 characters it is truncated and the user is warned.
.PARAMETER InputName The raw string to sanitise.
.OUTPUTS [string] A cleaned SAM Account Name safe for use with New-ADUser etc.
#>
function Get-SanitizedSAM {
param([string]$InputName)
# Remove all characters that AD rejects in SAM Account Names
$clean = ($InputName -replace '[\/\\\[\]:;|=,+*?<>@"°]', '').Trim()
# AD enforces a hard 20-character limit on sAMAccountName
if ($clean.Length -gt 20) {
Write-Warn "SAM name truncated to 20 characters."
$clean = $clean.Substring(0, 20)
}
return $clean
}
<#
.SYNOPSIS Prompts for input, loops until a non-empty value is entered.
.PARAMETER P Prompt label.
.PARAMETER D Default value shown in brackets; accepted by pressing ENTER.
.OUTPUTS [string] The user's input or the default value.
#>
function Read-NonEmpty {
param([string]$P, [string]$D = "")
do {
$hint = if ($D) { " [default: $D]" } else { "" }
$value = Read-Host " $P$hint"
if (-not $value -and $D) { $value = $D }
} while (-not $value)
return $value
}
<#
.SYNOPSIS Prompts for a SAM Account Name, sanitising the result automatically.
.NOTES Always use this instead of Read-Host when collecting account names.
#>
function Read-SAMName {
param([string]$P, [string]$D = "")
return Get-SanitizedSAM (Read-NonEmpty $P $D)
}
<#
.SYNOPSIS
Displays a numbered menu and returns the index of the chosen item.
.PARAMETER P Prompt text displayed above the option list.
.PARAMETER O Array of option strings.
.PARAMETER D Index of the default option (highlighted with ►).
.PARAMETER C Colour for the prompt text.
.OUTPUTS [int] Zero-based index of the selected option.
#>
function Read-Choice {
param([string]$P, [string[]]$O, [int]$D = 0, [ConsoleColor]$C = 'White')
Write-Host "`n $P" -ForegroundColor $C
for ($i = 0; $i -lt $O.Count; $i++) {
$marker = if ($i -eq $D) { "►" } else { " " }
Write-Host (" [{0}] {1} {2}" -f $i, $marker, $O[$i])
}
do {
$raw = Read-Host " Choice (default $D)"
if ($raw -eq "") { return $D }
$n = 0
} while (-not [int]::TryParse($raw, [ref]$n) -or $n -lt 0 -or $n -ge $O.Count)
return $n
}
<#
.SYNOPSIS Asks a Y/N confirmation question and returns $true for Yes.
#>
function Confirm-Action {
param([string]$M = "Proceed?")
return ((Read-Host "`n $M [Y/N]") -match "^[Yy]")
}
<#
.SYNOPSIS Pauses console output until the user presses ENTER.
#>
function Pause-Screen {
Write-Host "`n Press ENTER to continue..." -ForegroundColor DarkGray
$null = Read-Host
}
<#
.SYNOPSIS
Null-coalescing helper — returns $Value when it is non-null and non-empty,
otherwise returns $Default.
.DESCRIPTION
PowerShell 5.1 does not support the ?? operator (added in PS 7.0).
This function provides equivalent behaviour compatible with PS 5.1+.
.PARAMETER Value The value to test.
.PARAMETER Default Fallback value returned when $Value is null or empty.
#>
function Get-Coalesce {
param($Value, $Default = "")
if ($null -ne $Value -and "$Value" -ne '') { return $Value }
return $Default
}
<#
.SYNOPSIS
Guards write operations.
Returns $false and prints an error when the session is in read-only mode.
.OUTPUTS [bool] $true if writes are permitted; $false otherwise.
#>
function Assert-WriteAllowed {
if ($script:READONLY) {
Write-Fail "Session is READ-ONLY. No AD changes are permitted."
return $false
}
return $true
}
<#
.SYNOPSIS
Wraps a write operation in WhatIf (dry-run) or execute mode.
.DESCRIPTION
When $script:WHATIF is true, the action is logged as WHATIF and the script block
is NOT executed — zero changes are made to AD.
When $script:WHATIF is false, the script block runs inside a try/catch that logs
failures to the audit log and re-throws on error.
.PARAMETER Action Label for the audit log entry (e.g. "CREATE_gMSA").
.PARAMETER Target The SAM Account Name or object being acted upon.
.PARAMETER Block The PowerShell script block that performs the actual AD change.
#>
function Invoke-WhatIf {
param([string]$Action, [string]$Target, [scriptblock]$Block)
if ($script:WHATIF) {
# Dry-run: announce what would happen and log it, then stop
Write-Host " [WHATIF] Would execute: $Action on '$Target'" -ForegroundColor Magenta
Write-AuditLog $Action $Target "WHATIF" "DryRun=true"
return
}
try {
& $Block
}
catch {
Write-Fail "[$Action] on '$Target' failed: $($_.Exception.Message)"
Write-AuditLog $Action $Target "FAILURE" $_.Exception.Message
throw # Re-throw so the caller can handle or display the error
}
}
<#
.SYNOPSIS
Displays a collection of objects in pages, allowing navigation forward/back.
.DESCRIPTION
Prevents large result sets from flooding the console by slicing them into
pages of $PS rows. The user can type N (next), P (previous), or Q/ENTER (quit).
.PARAMETER Data The array of objects to display.
.PARAMETER Props Optional list of property names for Format-Table column selection.
.PARAMETER PS Page size override; defaults to $CFG.PageSize.
#>
function Show-Paged {
param([object[]]$Data, [string[]]$Props = @(), [int]$PS = 0)
if ($PS -eq 0) { $PS = $CFG.PageSize }
if (-not $Data -or $Data.Count -eq 0) { Write-Info "No data to display."; return }
$total = $Data.Count
$pages = [Math]::Ceiling($total / $PS)
$page = 0
do {
# Slice the current page out of the full result set
$slice = $Data | Select-Object -Skip ($page * $PS) -First $PS
if ($Props) { $slice | Format-Table $Props -AutoSize -Wrap }
else { $slice | Format-Table -AutoSize -Wrap }
Write-Host ("`n Page {0}/{1} ({2} total) [N]ext [P]rev [Q]uit" -f ($page + 1), $pages, $total) -ForegroundColor DarkGray
if ($pages -le 1) { break } # Single page: no navigation needed
$nav = Read-Host ""
if ($nav -match "^[Nn]" -and $page -lt $pages - 1) { $page++ }
elseif($nav -match "^[Pp]" -and $page -gt 0) { $page-- }
else { break }
} while ($true)
}
# ══════════════════════════════════════════════════════════════════════════════
# SMTP — Alert email delivery with DPAPI-secured password and retry logic
# ══════════════════════════════════════════════════════════════════════════════
<#
.SYNOPSIS
Decrypts the DPAPI-protected SMTP password stored in config.
.DESCRIPTION
The password is stored via ConvertFrom-SecureString (DPAPI, user + machine bound).
Returns a plain-text string for use with NetworkCredential, or $null if no
password is configured or decryption fails.
.OUTPUTS [string] or $null
.NOTES
DPAPI encryption is tied to the Windows user account and machine. The password
cannot be decrypted by another user account or on a different machine.
#>
function Get-SmtpPassword {
if (-not $CFG.SmtpPassEncrypted) { return $null }
try {
$ss = $CFG.SmtpPassEncrypted | ConvertTo-SecureString
$ptr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($ss)
return [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($ptr)
}
catch { return $null }
}
<#
.SYNOPSIS
Sends an HTML-formatted alert email using the configured SMTP settings.
.DESCRIPTION
Attempts delivery up to $CFG.SmtpRetryCount times with a 5-second pause
between attempts. Uses SSL if configured. Logs success or failure to audit log.
.PARAMETER Subject Email subject line.
.PARAMETER Body HTML body content.
.NOTES SMTP must be configured via Settings → Configure SMTP before this works.
#>
function Send-AlertEmail {
param([string]$Subject, [string]$Body)
# Abort early if SMTP is not configured
if (-not $CFG.SmtpServer -or -not $CFG.SmtpTo) {
Write-Warn "SMTP not configured. Use Settings → Configure SMTP to enable email alerts."
return
}
$maxAttempts = [Math]::Max(1, $CFG.SmtpRetryCount)
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
# Build the SMTP client with timeout (milliseconds) and SSL flag
$smtp = [System.Net.Mail.SmtpClient]::new($CFG.SmtpServer, $CFG.SmtpPort)
$smtp.EnableSsl = $CFG.SmtpUseSsl
$smtp.Timeout = $CFG.SmtpTimeoutSec * 1000 # Convert seconds to milliseconds
# Add credentials only when a username is configured
if ($CFG.SmtpUser) {
$smtp.Credentials = [System.Net.NetworkCredential]::new($CFG.SmtpUser, (Get-SmtpPassword))
}
# Compose the HTML message
$msg = [System.Net.Mail.MailMessage]::new($CFG.SmtpFrom, $CFG.SmtpTo, $Subject, $Body)
$msg.IsBodyHtml = $true
$smtp.Send($msg)
Write-OK "Alert email sent to $($CFG.SmtpTo)."
Write-AuditLog "EMAIL_ALERT" $CFG.SmtpTo "SUCCESS" "Subject=$Subject"
return # Success — exit retry loop
}
catch {
Write-Warn "SMTP attempt $attempt/$maxAttempts failed: $($_.Exception.Message)"
if ($attempt -lt $maxAttempts) {
Start-Sleep -Seconds 5 # Wait before retrying
}
else {
Write-Fail "All SMTP delivery attempts failed."
Write-AuditLog "EMAIL_ALERT" $CFG.SmtpTo "FAILURE" $_.Exception.Message
}
}
}
}
# ══════════════════════════════════════════════════════════════════════════════
# AUDIT ENGINE — Tamper-evident audit log with SHA-256 integrity hashing
# ══════════════════════════════════════════════════════════════════════════════
<#
.SYNOPSIS
Appends a structured entry to the CSV audit log and updates its SHA-256 hash.
.DESCRIPTION
Every create, modify, delete, test, and security operation calls this function.
The log records who did what, to which account, with what outcome, and when.
After each write the SHA-256 hash of the entire log file is recomputed and
stored in a companion .sha256 file for integrity verification.
.PARAMETER Action Short identifier for the operation (e.g. "CREATE", "DELETE").
.PARAMETER Target The SAM Account Name or object affected.
.PARAMETER Result Outcome string: SUCCESS | FAILURE | INFO | WHATIF.
.PARAMETER Details Optional free-text context (error messages, parameter values, etc.).
.NOTES
The hash file only proves the log has not changed since the last write from
THIS script. For a fully tamper-proof audit trail, forward logs to a SIEM.
#>
function Write-AuditLog {
param([string]$Action, [string]$Target, [string]$Result, [string]$Details = "")
# Build the structured log entry
$entry = [PSCustomObject]@{
Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
Operator = "$env:USERDOMAIN\$env:USERNAME"
Hostname = $env:COMPUTERNAME
Action = $Action
Target = $Target
Result = $Result
Details = $Details
}
# Format as a CSV row — quote every field to handle commas in values
$line = '"' + ($entry.PSObject.Properties.Value -join '","') + '"'
$needHeader = (-not (Test-Path $AUDIT_LOG)) -or (Get-Item $AUDIT_LOG -EA SilentlyContinue).Length -eq 0
try {
# Write header row only when creating a new log file
if ($needHeader) {
'"Timestamp","Operator","Hostname","Action","Target","Result","Details"' |
Set-Content $AUDIT_LOG -Encoding UTF8
}
# Append the data row
$line | Add-Content $AUDIT_LOG -Encoding UTF8
# Recompute and store the SHA-256 hash for integrity verification
(Get-FileHash $AUDIT_LOG -Algorithm SHA256).Hash |
Set-Content $AUDIT_HASH -Encoding UTF8
}
catch {
# Log failures are non-fatal — display a warning but continue
Write-Warn "Audit log write failed: $($_.Exception.Message)"
}
# Echo the entry to the console with result-appropriate colour
$colour = switch ($Result) {
"SUCCESS" { "Green" }
"FAILURE" { "Red" }
"WHATIF" { "Magenta" }
default { "Gray" }
}
Write-Host (" [AUDIT] {0} → {1} ({2})" -f $Action, $Target, $Result) -ForegroundColor $colour
}
<#
.SYNOPSIS
Verifies that the audit log has not been tampered with since the last write.
.DESCRIPTION
Recomputes the SHA-256 hash of the audit log and compares it to the stored
companion hash. A mismatch indicates the log was modified outside of this script.
.NOTES
Run this before exporting the log for compliance review to confirm chain of custody.
#>
function Test-AuditLogIntegrity {
if (-not (Test-Path $AUDIT_LOG)) { Write-Info "No audit log found. Nothing to verify."; return }
if (-not (Test-Path $AUDIT_HASH)) { Write-Warn "No integrity hash file found — log integrity unverified."; return }
$storedHash = (Get-Content $AUDIT_HASH -Raw).Trim()
$currentHash = (Get-FileHash $AUDIT_LOG -Algorithm SHA256).Hash
if ($storedHash -eq $currentHash) {
Write-OK "Audit log integrity VERIFIED — SHA-256 hash matches."
}
else {
Write-Fail "INTEGRITY FAILURE — SHA-256 hash mismatch! The audit log may have been tampered with."
Write-Fail "Stored : $storedHash"
Write-Fail "Current: $currentHash"
Write-AuditLog "INTEGRITY_FAILURE" $AUDIT_LOG "FAILURE" "HashMismatch"
}
}
<#
.SYNOPSIS Exports the audit log to a JSON file suitable for SIEM ingestion.
.PARAMETER F Full file path for the output JSON file.
#>
function Export-SIEMJson {
param([string]$F)
if (Test-Path $AUDIT_LOG) {
Import-Csv $AUDIT_LOG | ConvertTo-Json -Depth 5 | Set-Content $F -Encoding UTF8
Write-OK "SIEM JSON exported: $F"
}
}
<#
.SYNOPSIS Exports the audit log in Common Event Format (CEF) for Splunk/QRadar.
.PARAMETER F Full file path for the output CEF file.
.NOTES CEF severity: 3=LOW (success), 7=HIGH (failure).
#>
function Export-SIEMCef {
param([string]$F)
if (Test-Path $AUDIT_LOG) {
Import-Csv $AUDIT_LOG | ForEach-Object {
$severity = switch ($_.Result) { "SUCCESS" { "3" } "FAILURE" { "7" } default { "1" } }
"CEF:0|ADSvcAcctMgr|$SCRIPT_VERSION|$($_.Action)|$($_.Action)|$severity|" +
"suser=$($_.Operator) dhost=$($_.Hostname) duser=$($_.Target) " +
"msg=$($_.Details) rt=$($_.Timestamp)"
} | Set-Content $F -Encoding UTF8
Write-OK "SIEM CEF exported: $F"
}
}
<#
.SYNOPSIS Exports the audit log in RFC-3164 Syslog format.
.PARAMETER F Full file path for the output Syslog file.
.NOTES Priority: 11=FAILURE (user.warning), 6=SUCCESS (user.info), 5=other (user.notice).
#>
function Export-SIEMSyslog {
param([string]$F)
if (Test-Path $AUDIT_LOG) {
Import-Csv $AUDIT_LOG | ForEach-Object {
$priority = switch ($_.Result) { "FAILURE" { "11" } "SUCCESS" { "6" } default { "5" } }
$timestamp = [datetime]::Parse($_.Timestamp).ToString("MMM dd HH:mm:ss")
"<$priority>$timestamp $env:COMPUTERNAME ADSvcAcctMgr: " +
"Action=$($_.Action) Target=$($_.Target) Result=$($_.Result) " +
"Op=$($_.Operator) Details=$($_.Details)"
} | Set-Content $F -Encoding UTF8
Write-OK "SIEM Syslog exported: $F"
}
}
# ══════════════════════════════════════════════════════════════════════════════
# ROLE & SESSION SETUP — Determine operator privileges at startup
# ══════════════════════════════════════════════════════════════════════════════
<#
.SYNOPSIS
Detects the current operator's effective AD privileges.
.DESCRIPTION
Checks Domain Admins membership first (full rights), then local Administrators
(delegated), falling back to ReadOnly if neither is found or an error occurs.
.OUTPUTS [string] "DomainAdmin" | "DelegatedAdmin" | "ReadOnly"
#>
function Get-SessionRole {
try {
# Check if the current user is a (direct or nested) member of Domain Admins
$isDomAdmin = Get-ADGroupMember "Domain Admins" -Recursive -EA SilentlyContinue |
Where-Object { $_.SamAccountName -eq $env:USERNAME }
if ($isDomAdmin) { return "DomainAdmin" }
# Check local Administrators token group for delegated admin scenarios
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
$isLocalAdmin = $identity.Groups | Where-Object {
try { ($_.Translate([System.Security.Principal.NTAccount])).Value -match "Administrators" }
catch { $false }
}
return if ($isLocalAdmin) { "DelegatedAdmin" } else { "ReadOnly" }
}
catch { return "ReadOnly" }
}
<#
.SYNOPSIS
Runs at startup: detects role, applies -ReadOnly/-DryRun flags, and prompts
the operator to confirm their session mode.
.DESCRIPTION
Operators can always choose to downgrade to read-only even when they have
write permissions. A DryRun session allows them to preview all operations
without committing any changes to Active Directory.
#>
function Invoke-RoleSetup {
$detected = Get-SessionRole
Write-Info "Detected role: $detected"
if ($script:READONLY -or $detected -eq "ReadOnly") {
# Force read-only when the -ReadOnly switch was passed or no write rights detected
$script:READONLY = $true
$script:ROLE = "ReadOnly"
Write-Warn "READ-ONLY session — no AD changes will be made in this session."
}
else {
# Let the operator choose their access level for this session
$choice = Read-Choice "Session mode:" @(
"Full access (create / modify / delete)",
"Read-only (view / audit / report only)"
) 0
$script:READONLY = ($choice -eq 1)
$script:ROLE = if ($script:READONLY) { "ReadOnly" } else { $detected }
}
if ($script:WHATIF) {
Write-Warn "DRY-RUN active — all write operations will be previewed but NOT committed."
}
Write-AuditLog "SESSION_START" "SYSTEM" "INFO" `
"Role=$($script:ROLE) WhatIf=$($script:WHATIF) Mode=$Mode"
}
# ══════════════════════════════════════════════════════════════════════════════
# AD HELPERS — Reusable Active Directory utility functions
# ══════════════════════════════════════════════════════════════════════════════
<#
.SYNOPSIS
Returns a hashtable of key domain properties for a given FQDN (or current domain).
.PARAMETER TargetDomain Domain FQDN. Empty string = current domain.
.OUTPUTS [hashtable] Keys: FQDN, DN, NetBIOS, PDC
#>
function Get-DomainInfo {
param([string]$TargetDomain = "")
$params = @{}
if ($TargetDomain) { $params.Identity = $TargetDomain }
$dom = Get-ADDomain @params
return @{
FQDN = $dom.DNSRoot
DN = $dom.DistinguishedName
NetBIOS = $dom.NetBIOSName
PDC = $dom.PDCEmulator
}
}
<#
.SYNOPSIS
Enumerates all domains in the current (and optionally trusted) forests.
.PARAMETER RootDomain Optional root forest domain FQDN. Empty = current forest.
.OUTPUTS [hashtable[]] Array of domain info hashtables (FQDN, DN, NetBIOS, PDC).
.NOTES Trusted forests are read from $CFG.TrustedForests.
#>
function Get-ForestDomains {
param([string]$RootDomain = "")
$domains = @()
try {
$forestParams = @{}
if ($RootDomain) { $forestParams.Identity = $RootDomain }
$forest = Get-ADForest @forestParams
# Resolve each domain in the primary forest
$domains += $forest.Domains | ForEach-Object {
try { Get-DomainInfo $_ } catch { Write-Warn "Cannot reach domain '$_'." }
}
# Extend to configured trusted forests
foreach ($trustedForest in $CFG.TrustedForests) {
try {
$tf2 = Get-ADForest -Identity $trustedForest -EA Stop
$domains += $tf2.Domains | ForEach-Object {
try { Get-DomainInfo $_ } catch {}
}
}
catch { Write-Warn "Cannot reach trusted forest: $trustedForest" }
}
}
catch { Write-Fail "Forest enumeration failed: $($_.Exception.Message)" }
return $domains
}
<#
.SYNOPSIS Returns $true when the supplied OU Distinguished Name exists in AD.
.PARAMETER OU OU Distinguished Name to validate (e.g. "OU=ServiceAccounts,DC=corp,DC=com").
#>
function Get-ValidatedOU {
param([string]$OU)
try { Get-ADOrganizationalUnit -Identity $OU -EA Stop | Out-Null; return $true }
catch { return $false }
}
<#
.SYNOPSIS
Resolves what type of AD account a given SAM Account Name represents.
.DESCRIPTION
Tries Get-ADUser first, then Get-ADServiceAccount.
Distinguishes MSA (msDS-ManagedServiceAccount) from gMSA (msDS-GroupManagedServiceAccount).
.PARAMETER Sam The sAMAccountName to resolve.
.PARAMETER Server Optional DC name to query (defaults to PDC).
.OUTPUTS [string] or $null "Standard" | "MSA" | "gMSA" | $null (not found)
#>
function Resolve-AccountType {
param([string]$Sam, [string]$Server = "")
$serverParam = @{}
if ($Server) { $serverParam.Server = $Server }
try { Get-ADUser $Sam -EA Stop @serverParam | Out-Null; return "Standard" } catch {}
try {
$sa = Get-ADServiceAccount $Sam -Properties ObjectClass -EA Stop @serverParam
return if ($sa.ObjectClass -eq "msDS-GroupManagedServiceAccount") { "gMSA" } else { "MSA" }
}
catch {}
return $null # Account not found in this domain
}
<#
.SYNOPSIS
Generates a cryptographically random password of the specified length.
.DESCRIPTION
Uses System.Security.Cryptography.RandomNumberGenerator (CSPRNG) to ensure
each character is drawn from a uniform distribution across the character set.
The character set includes upper, lower, digits, and common symbols.
.PARAMETER Len Desired password length. Defaults to 24 characters.
.OUTPUTS [System.Security.SecureString] A SecureString holding the generated password.
.NOTES
Use Get-PlainText to convert to plain text only when absolutely necessary
(e.g., credential export). Never log plain-text passwords.
#>
function New-SecurePassword {
param([int]$Len = 24)
$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+'
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
$bytes = [byte[]]::new($Len)
$rng.GetBytes($bytes)
# Map each byte to a character index using modulo — uniform only when char set
# size is a power-of-2 divisor of 256; for non-powers-of-2 there is slight bias.
# For service account passwords this is an acceptable trade-off.
$plain = -join ($bytes | ForEach-Object { $chars[$_ % $chars.Length] })
return ConvertTo-SecureString $plain -AsPlainText -Force
}
<#
.SYNOPSIS Converts a SecureString to a plain-text string for display or export.
.NOTES Only call this at the very last moment before output. Never log the result.
#>
function Get-PlainText {
param([System.Security.SecureString]$SS)
$ptr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($SS)
return [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($ptr)
}
<#
.SYNOPSIS
Returns the resultant Password Settings Object (PSO/Fine-Grained Policy) for an account.
.DESCRIPTION
A PSO overrides the default domain password policy for the specified account.
Returns $null if no PSO is applied (domain default is in effect).
.PARAMETER Sam SAM Account Name to check.
.OUTPUTS PSO object or $null
#>
function Get-AccountPSO {
param([string]$Sam)
try { return Get-ADUserResultantPasswordPolicy -Identity $Sam -EA Stop }
catch { return $null } # No PSO, or insufficient rights — both return null
}
<#
.SYNOPSIS Tests whether a SAM Account Name matches any configured naming convention pattern.
.PARAMETER Sam The sAMAccountName to test.
.OUTPUTS [bool] $true if at least one pattern matches.
#>
function Test-NamingConvention {
param([string]$Sam)
foreach ($pattern in $CFG.NamingPatterns) {
if ($Sam -match $pattern) { return $true }
}
return $false
}
<#
.SYNOPSIS Persists the current $CFG object to config.json and logs the operation.
#>
function Save-Config {
$CFG | ConvertTo-Json -Depth 5 | Set-Content $CONFIG_FILE -Encoding UTF8
Write-OK "Configuration saved to $CONFIG_FILE."
Write-AuditLog "SETTINGS_SAVE" "SYSTEM" "SUCCESS" ""
}
# ══════════════════════════════════════════════════════════════════════════════
# CREATE MODULE — Account creation: wizard, clone, and bulk CSV
# ══════════════════════════════════════════════════════════════════════════════
<#
.SYNOPSIS Entry point for the Create submenu.
.PARAMETER Dom Domain info hashtable from Get-DomainInfo.
#>
function Invoke-CreateMenu {
param([hashtable]$Dom)
if (-not (Assert-WriteAllowed)) { Pause-Screen; return }
Write-Header "CREATE SERVICE ACCOUNT"
$choice = Read-Choice "Choose creation method:" @(
"Single account wizard",
"Clone an existing account",
"Bulk import from CSV file",
"Generate CSV bulk-import template"
)
switch ($choice) {
0 { New-SingleAccount $Dom }
1 { New-ClonedAccount $Dom }
2 { New-BulkFromCSV $Dom }
3 {
# Write a template CSV to the Desktop with one example of each account type
$templatePath = Join-Path ([Environment]::GetFolderPath("Desktop")) "SvcAcct_BulkTemplate.csv"
@"
Type,SamName,DisplayName,Description,OU,Department,Owner,PwdNeverExpires,BindHost,AllowedPrincipals,PwdInterval,KerberosEncryption
Standard,svc_myapp,MyApp Service,Service account for MyApp,"OU=ServiceAccounts,DC=domain,DC=com",IT,jsmith,true,,,,
MSA,msa_webapp,WebApp MSA,MSA for WebApp,"OU=ServiceAccounts,DC=domain,DC=com",,,,WEBSERVER01,,,
gMSA,gmsa_api,API gMSA,gMSA for API Cluster,"OU=ServiceAccounts,DC=domain,DC=com",,,,,"SG_APIFarm,APINODE01`$",30,AES256
"@ | Set-Content $templatePath -Encoding UTF8
Write-OK "Template saved to: $templatePath"
Write-Info "Edit the template and use 'Bulk import from CSV' to create all accounts at once."
}
}
Pause-Screen
}
<#
.SYNOPSIS
Interactive wizard to create a single Standard, MSA, or gMSA service account.
.DESCRIPTION
Walks the operator through account type selection, naming validation, OU selection,
type-specific configuration (password policy, SPN, KDS key, delegation principals),
confirmation, and optional post-creation tasks (group assignments, SPNs).
Credential export (Standard accounts) is offered after creation.
.PARAMETER Dom Domain info hashtable.
.NOTES
gMSA creation requires a KDS Root Key in the domain.