diff --git a/Changelog.md b/Changelog.md
index 6d1096a..0e8d701 100644
--- a/Changelog.md
+++ b/Changelog.md
@@ -1,5 +1,15 @@
# Changelog
+## v1.117.0
+
+Service certificate binding audit — a new module (**78-CertificateAudit**) surfaced under **Security & Access → [12] Certificate Binding Audit**, plus a read-only CLI action.
+
+- **`CertBindingAudit`** (read-only) — reports which certificate is actually bound to the **RDP-Tcp** listener and the **WinRM HTTPS** listener, with subject, days-to-expiry, and whether the certificate has a usable private key. JSON-aware; makes no changes. This is binding-aware — unlike the generic certificate-expiry check, it tells you which cert each service is presenting, so you can catch an expiring RDP/WinRM binding before clients see TLS failures.
+
+A note on scope: automated **rotation** of the RDP listener was prototyped for this release but **deferred** after an adversarial security review. Re-binding RDP safely requires the target certificate's private key to be readable by the RDP service account (`NETWORK SERVICE`) — a freshly generated self-signed cert is not, by default — and getting that wrong can break RDP TLS and lock an administrator out of the only remote-access path. Combined with inconsistent CIM writability of `SSLCertificateSHA1Hash` across Windows builds, that mutation needs validation on a live, elevated, RDP-enabled server before it ships. The audit surfaces exactly what to rotate manually and when, and the rotation will follow once it can be verified safely.
+
+New module 78-CertificateAudit. Modules: 78 → 79. CLI actions: 198 → 199.
+
## v1.116.0
NTP clock-tamper protection + time-authentication audit — a new **NTP Configuration → [8] Clock-Tamper Protection** item plus a read-only CLI action.
diff --git a/Header.ps1 b/Header.ps1
index f48d385..b67475e 100644
--- a/Header.ps1
+++ b/Header.ps1
@@ -30,7 +30,7 @@
7h3 4b1d3r
.VERSION
- 1.116.0
+ 1.117.0
.LAST UPDATED
05/23/2026
@@ -1391,7 +1391,7 @@
param(
# CLI headless mode: run a specific action without interactive menus
# Usage: RackStack.exe -Action Cleanup [-Tier Standard] [-Silent] [-OutputFormat JSON]
- [ValidateSet('Cleanup', 'Debloat', 'HealthCheck', 'Batch', 'QuickScan', 'Inventory', 'DriftCheck', 'Snapshot', 'Compliance', 'Harden', 'Remediate', 'Aggregate', 'Compare', 'Export', 'Trend', 'CertCheck', 'ReportHTML', 'ListeningPorts', 'SoftwareList', 'Uptime', 'ServiceAudit', 'EventAudit', 'NetInfo', 'ScheduledExport', 'ValidateConfig', 'Watch', 'Query', 'Diff', 'Baseline', 'Alert', 'FleetScan', 'PatchStatus', 'UserAudit', 'FirewallAudit', 'TaskAudit', 'DiskAudit', 'TLSAudit', 'SMBAudit', 'DriverAudit', 'TimeAudit', 'BootAudit', 'GPOAudit', 'MemoryAudit', 'ProcessAudit', 'BackupAudit', 'ShareAudit', 'DNSAudit', 'PowerAudit', 'RegistryAudit', 'ProfileAudit', 'HyperVAudit', 'NetworkAudit', 'StorageAudit', 'FeatureAudit', 'AutoStartAudit', 'BIOSAudit', 'ClusterAudit', 'AuditPolicyAudit', 'EnvAudit', 'CrashAudit', 'LocalGroupAudit', 'WMIAudit', 'TempAudit', 'UpdatePolicyAudit', 'IISAudit', 'SSHAudit', 'BitLockerAudit', 'PrintAudit', 'CredGuardAudit', 'PortAudit', 'AntivirusAudit', 'DotNetAudit', 'RDPAudit', 'VPNAudit', 'HostsFileAudit', 'NetStatAudit', 'LicenseAudit', 'USBDeviceAudit', 'AppLockerAudit', 'EventSubAudit', 'HotfixAudit', 'SysInfoAudit', 'LogonAudit', 'ACLAudit', 'RecoveryAudit', 'ServiceAccountAudit', 'ProxyAudit', 'PendingRebootAudit', 'PageFileAudit', 'CPUAudit', 'DefenderExclusionAudit', 'KerberosAudit', 'DHCPAudit', 'NUMAAudit', 'SymlinkAudit', 'StartupScriptAudit', 'SecureChannelAudit', 'ComObjectAudit', 'FirewallLogAudit', 'ScheduledRebootAudit', 'PowerShellAudit', 'RouteTableAudit', 'TokenPrivilegeAudit', 'WindowsCapabilityAudit', 'ARPTableAudit', 'LocaleAudit', 'TaskHistoryAudit', 'NTFSAudit', 'Win11Cleanup', 'DarkMode', 'LightMode', 'iSCSIAudit', 'NICTeamAudit', 'SMBSessionAudit', 'WindowsUpdateAudit', 'ClusterQuorumAudit', 'S2DAudit', 'VirtualSwitchAudit', 'MPIOPathAudit', 'ServiceRecoveryAudit', 'VMOvercommitAudit', 'DedupAudit', 'ClusterNetworkAudit', 'ReplicaLagAudit', 'HandleLeakAudit', 'ShadowCopyAudit', 'QoSPolicyAudit', 'LiveMigrationAudit', 'DomainTrustAudit', 'DiskLatencyAudit', 'NICOffloadAudit', 'StorageTimeoutAudit', 'EventLogCapacityAudit', 'TcpSettingsAudit', 'WinRMAudit', 'ClusterHealthScore', 'VMInventoryExport', 'VMSnapshotAudit', 'StorageHealthScore', 'CSVSpaceAudit', 'SMBConnectionAudit', 'VolumeLabelAudit', 'NICErrorAudit', 'VMResourceWaste', 'HealthDashboard', 'SCCMClientAudit', 'SCOMAgentAudit', 'WACConnectivityAudit', 'AzureADAudit', 'ServerScore', 'FleetReport', 'PasswordPolicy', 'FirewallRuleAudit', 'GPResultAudit', 'DNSCacheAudit', 'TPMAudit', 'SecureBootAudit', 'TimeSkewAudit', 'NetworkProfileAudit', 'InsecureServiceAudit', 'SelfTest', 'CheckForUpdate', 'ExportLogs', 'UpdateSelf', 'Rollback', 'ScheduleUpdateCheck', 'Dashboard', 'History', 'Replay', 'AzureArcEnroll', 'DefenderEndpointOnboard', 'WSUSSetup', 'ADCSSetup', 'StorageMigrationSetup', 'GPOBackup', 'GPODrift', 'JEAList', 'NPSSetup', 'AlwaysOnVPNSetup', 'CISScan', 'SIEMSetup', 'SIEMStatus', 'WACSetup', 'WACStatus', 'VHDXEncryptionAudit', 'ADRecycleBin', 'ClusterValidationReport', 'SmbEnforce', 'SmbSecurityCheck', 'PrintServerAudit', 'NtpHardeningAudit')]
+ [ValidateSet('Cleanup', 'Debloat', 'HealthCheck', 'Batch', 'QuickScan', 'Inventory', 'DriftCheck', 'Snapshot', 'Compliance', 'Harden', 'Remediate', 'Aggregate', 'Compare', 'Export', 'Trend', 'CertCheck', 'ReportHTML', 'ListeningPorts', 'SoftwareList', 'Uptime', 'ServiceAudit', 'EventAudit', 'NetInfo', 'ScheduledExport', 'ValidateConfig', 'Watch', 'Query', 'Diff', 'Baseline', 'Alert', 'FleetScan', 'PatchStatus', 'UserAudit', 'FirewallAudit', 'TaskAudit', 'DiskAudit', 'TLSAudit', 'SMBAudit', 'DriverAudit', 'TimeAudit', 'BootAudit', 'GPOAudit', 'MemoryAudit', 'ProcessAudit', 'BackupAudit', 'ShareAudit', 'DNSAudit', 'PowerAudit', 'RegistryAudit', 'ProfileAudit', 'HyperVAudit', 'NetworkAudit', 'StorageAudit', 'FeatureAudit', 'AutoStartAudit', 'BIOSAudit', 'ClusterAudit', 'AuditPolicyAudit', 'EnvAudit', 'CrashAudit', 'LocalGroupAudit', 'WMIAudit', 'TempAudit', 'UpdatePolicyAudit', 'IISAudit', 'SSHAudit', 'BitLockerAudit', 'PrintAudit', 'CredGuardAudit', 'PortAudit', 'AntivirusAudit', 'DotNetAudit', 'RDPAudit', 'VPNAudit', 'HostsFileAudit', 'NetStatAudit', 'LicenseAudit', 'USBDeviceAudit', 'AppLockerAudit', 'EventSubAudit', 'HotfixAudit', 'SysInfoAudit', 'LogonAudit', 'ACLAudit', 'RecoveryAudit', 'ServiceAccountAudit', 'ProxyAudit', 'PendingRebootAudit', 'PageFileAudit', 'CPUAudit', 'DefenderExclusionAudit', 'KerberosAudit', 'DHCPAudit', 'NUMAAudit', 'SymlinkAudit', 'StartupScriptAudit', 'SecureChannelAudit', 'ComObjectAudit', 'FirewallLogAudit', 'ScheduledRebootAudit', 'PowerShellAudit', 'RouteTableAudit', 'TokenPrivilegeAudit', 'WindowsCapabilityAudit', 'ARPTableAudit', 'LocaleAudit', 'TaskHistoryAudit', 'NTFSAudit', 'Win11Cleanup', 'DarkMode', 'LightMode', 'iSCSIAudit', 'NICTeamAudit', 'SMBSessionAudit', 'WindowsUpdateAudit', 'ClusterQuorumAudit', 'S2DAudit', 'VirtualSwitchAudit', 'MPIOPathAudit', 'ServiceRecoveryAudit', 'VMOvercommitAudit', 'DedupAudit', 'ClusterNetworkAudit', 'ReplicaLagAudit', 'HandleLeakAudit', 'ShadowCopyAudit', 'QoSPolicyAudit', 'LiveMigrationAudit', 'DomainTrustAudit', 'DiskLatencyAudit', 'NICOffloadAudit', 'StorageTimeoutAudit', 'EventLogCapacityAudit', 'TcpSettingsAudit', 'WinRMAudit', 'ClusterHealthScore', 'VMInventoryExport', 'VMSnapshotAudit', 'StorageHealthScore', 'CSVSpaceAudit', 'SMBConnectionAudit', 'VolumeLabelAudit', 'NICErrorAudit', 'VMResourceWaste', 'HealthDashboard', 'SCCMClientAudit', 'SCOMAgentAudit', 'WACConnectivityAudit', 'AzureADAudit', 'ServerScore', 'FleetReport', 'PasswordPolicy', 'FirewallRuleAudit', 'GPResultAudit', 'DNSCacheAudit', 'TPMAudit', 'SecureBootAudit', 'TimeSkewAudit', 'NetworkProfileAudit', 'InsecureServiceAudit', 'SelfTest', 'CheckForUpdate', 'ExportLogs', 'UpdateSelf', 'Rollback', 'ScheduleUpdateCheck', 'Dashboard', 'History', 'Replay', 'AzureArcEnroll', 'DefenderEndpointOnboard', 'WSUSSetup', 'ADCSSetup', 'StorageMigrationSetup', 'GPOBackup', 'GPODrift', 'JEAList', 'NPSSetup', 'AlwaysOnVPNSetup', 'CISScan', 'SIEMSetup', 'SIEMStatus', 'WACSetup', 'WACStatus', 'VHDXEncryptionAudit', 'ADRecycleBin', 'ClusterValidationReport', 'SmbEnforce', 'SmbSecurityCheck', 'PrintServerAudit', 'NtpHardeningAudit', 'CertBindingAudit')]
[string]$Action,
[ValidateSet('Light', 'Standard', 'Aggressive')]
diff --git a/Modules/00-Initialization.ps1 b/Modules/00-Initialization.ps1
index 2fdffe4..73db9bd 100644
--- a/Modules/00-Initialization.ps1
+++ b/Modules/00-Initialization.ps1
@@ -225,7 +225,7 @@ if (-not $PSCommandPath -and $script:ScriptPath) {
if (-not $script:ModuleRoot -and $script:ScriptPath) {
$script:ModuleRoot = [System.IO.Path]::GetDirectoryName($script:ScriptPath)
}
-$script:ScriptVersion = "1.116.0"
+$script:ScriptVersion = "1.117.0"
$script:ScriptStartTime = Get-Date
# Post-update cleanup: UpdateSelf / Rollback leave a `.pending-delete` sibling next to RackStack.exe.
diff --git a/Modules/34-Help.ps1 b/Modules/34-Help.ps1
index 52b5294..96a64b3 100644
--- a/Modules/34-Help.ps1
+++ b/Modules/34-Help.ps1
@@ -253,7 +253,7 @@ function Search-HelpTopics {
@{ Title = "Performance"; Keywords = @("performance", "cpu", "memory", "disk", "io", "bandwidth", "dashboard", "process"); Description = "Live performance dashboard with CPU, memory, disk I/O, and network bandwidth monitoring" }
@{ Title = "Licensing & NTP"; Keywords = @("license", "activation", "kms", "avma", "ntp", "time", "timezone", "clock"); Description = "Windows licensing status (KMS/AVMA/Retail), NTP configuration, time sync, and timezone setup" }
@{ Title = "VM Management"; Keywords = @("checkpoint", "snapshot", "export", "import", "migration", "vhd", "iso"); Description = "VM checkpoints, export/import, migration readiness, VHD health, and ISO inventory" }
- @{ Title = "CLI Actions"; Keywords = @("cli", "action", "headless", "automation", "fleet", "json", "audit", "scan", "score", "dashboard", "monitor", "policy", "sla", "netmap", "validate"); Description = "198 CLI actions for headless automation. Run -ListActions to see all. JSON output via -OutputFormat JSON. Key: ServerScore, HealthDashboard, FleetReport, CISScan, NPSSetup, AlwaysOnVPNSetup, SIEMStatus." }
+ @{ Title = "CLI Actions"; Keywords = @("cli", "action", "headless", "automation", "fleet", "json", "audit", "scan", "score", "dashboard", "monitor", "policy", "sla", "netmap", "validate"); Description = "199 CLI actions for headless automation. Run -ListActions to see all. JSON output via -OutputFormat JSON. Key: ServerScore, HealthDashboard, FleetReport, CISScan, NPSSetup, AlwaysOnVPNSetup, SIEMStatus." }
@{ Title = "SelfTest Action"; Keywords = @("selftest", "self-test", "diagnose", "diagnostic", "verify", "healthcheck", "sanity"); Description = "Internal diagnostic. -Action SelfTest checks PS version, elevation, module count, version consistency, defaults.json validity, temp path writability, FileServer reachability, and agent installer config. Exit 1 on any failure. Use -OutputFormat JSON for structured output." }
@{ Title = "Security Audits"; Keywords = @("security", "audit", "hardening", "compliance", "tls", "smb", "kerberos", "credguard", "applocker", "bitlockeraudit", "defenderexclusionaudit", "audit-policy", "secureboot", "tpm"); Description = "Security-focused CLI audits: TLSAudit, SMBAudit, KerberosAudit, CredGuardAudit, AppLockerAudit, BitLockerAudit, DefenderExclusionAudit, AuditPolicyAudit, SecureBootAudit, TPMAudit, UserAudit, LogonAudit, InsecureServiceAudit, RegistryAudit. All support -OutputFormat JSON." }
@{ Title = "Network Audits"; Keywords = @("netaudit", "dns", "firewall-audit", "firewalllog", "arp", "route", "tcp", "netstat", "dhcp", "netprofile", "winrm", "qos", "nicoffload"); Description = "Network audits: DNSAudit, DNSCacheAudit, FirewallAudit, FirewallRuleAudit, FirewallLogAudit, ARPTableAudit, RouteTableAudit, TcpSettingsAudit, NetStatAudit, DHCPAudit, NetworkProfileAudit, WinRMAudit, QoSPolicyAudit, NICOffloadAudit, NICErrorAudit, HostsFileAudit, VPNAudit, ProxyAudit." }
diff --git a/Modules/48-MenuDisplay.ps1 b/Modules/48-MenuDisplay.ps1
index 7372da5..073de2e 100644
--- a/Modules/48-MenuDisplay.ps1
+++ b/Modules/48-MenuDisplay.ps1
@@ -540,6 +540,13 @@ function Show-SecurityAccessMenu {
Write-MenuItem "[11] Generate Strong Password"
Write-OutputColor " └────────────────────────────────────────────────────────────────────────┘" -color "Info"
Write-OutputColor "" -color "Info"
+
+ Write-OutputColor " ┌────────────────────────────────────────────────────────────────────────┐" -color "Info"
+ Write-OutputColor " │$(" CERTIFICATES".PadRight(72))│" -color "Info"
+ Write-OutputColor " ├────────────────────────────────────────────────────────────────────────┤" -color "Info"
+ Write-MenuItem "[12] Certificate Binding Audit"
+ Write-OutputColor " └────────────────────────────────────────────────────────────────────────┘" -color "Info"
+ Write-OutputColor "" -color "Info"
Write-OutputColor " [B] ◄ Back to Server Config" -color "Info"
Write-OutputColor "" -color "Info"
diff --git a/Modules/49-MenuRunner.ps1 b/Modules/49-MenuRunner.ps1
index 3545ea0..96ba879 100644
--- a/Modules/49-MenuRunner.ps1
+++ b/Modules/49-MenuRunner.ps1
@@ -289,9 +289,10 @@ function Start-Show-SecurityAccessMenu {
"9" { Disable-BuiltInAdminAccount }
"10" { Show-LocalAccountAudit }
"11" { New-StrongPassword; Write-PressEnter }
+ "12" { Show-CertificateBindings; Write-PressEnter }
"back" { return }
default {
- Write-OutputColor " Invalid choice. Enter 1-11 or B." -color "Error"
+ Write-OutputColor " Invalid choice. Enter 1-12 or B." -color "Error"
Start-Sleep -Milliseconds 500
}
}
diff --git a/Modules/50-EntryPoint.ps1 b/Modules/50-EntryPoint.ps1
index 2994a5c..9556cb3 100644
--- a/Modules/50-EntryPoint.ps1
+++ b/Modules/50-EntryPoint.ps1
@@ -416,6 +416,7 @@ function Assert-Elevation {
@{ Action = 'SmbEnforce'; Description = 'Enforce SMB server signing + encryption (reversible)' }
@{ Action = 'PrintServerAudit'; Description = 'Read-only: report print-spooler posture, queue depth, orphaned ports + unused drivers (JSON-aware)' }
@{ Action = 'NtpHardeningAudit'; Description = 'Read-only: report W32Time clock-tamper posture (phase-correction limits, auth mode) (JSON-aware)' }
+ @{ Action = 'CertBindingAudit'; Description = 'Read-only: report RDP/WinRM listener certificate bindings + expiry (JSON-aware)' }
@{ Action = 'Batch'; Description = 'JSON-driven full configuration' }
)
if ($script:CLIOutputFormat -eq 'JSON') {
@@ -2083,6 +2084,11 @@ footer{text-align:center;color:#999;font-size:12px;padding:16px}
$ntpAuditOk = Start-NtpHardeningAudit
[Environment]::Exit([int](-not $ntpAuditOk))
}
+ 'CertBindingAudit' {
+ # Read-only RDP/WinRM listener certificate bindings (JSON-aware).
+ $certAuditOk = Start-CertBindingAudit
+ [Environment]::Exit([int](-not $certAuditOk))
+ }
'Batch' {
if (-not $script:CLIConfig) {
Write-OutputColor " ERROR: -Action Batch requires -Config " -color "Error"
diff --git a/Modules/78-CertificateAudit.ps1 b/Modules/78-CertificateAudit.ps1
new file mode 100644
index 0000000..f1d0da4
--- /dev/null
+++ b/Modules/78-CertificateAudit.ps1
@@ -0,0 +1,129 @@
+#region ===== CERTIFICATE BINDING AUDIT =====
+# Read-only audit of the certificates bound to this host's service listeners.
+# Unlike the generic certificate-expiry check, this is binding-aware: it reports
+# WHICH certificate is actually presented by the RDP-Tcp listener and the WinRM
+# HTTPS listener, with subject and days-to-expiry, so you can spot a binding that
+# is about to expire before clients start seeing TLS warnings or failures.
+#
+# Automated rotation is intentionally NOT performed here. Re-binding the RDP
+# listener safely requires the target certificate's private key to be readable by
+# the RDP service account (NETWORK SERVICE) — a freshly generated self-signed cert
+# is not, by default, which would break RDP TLS and can lock an administrator out
+# of the only remote-access path. Doing that correctly (private-key ACL grant +
+# the documented WMI set path) needs validation on a live, elevated, RDP-enabled
+# server, so rotation is deferred to a future release rather than shipped untested.
+# For now this module surfaces the bindings so you know exactly what to rotate
+# manually (Remote Desktop Session Host Configuration, or certlm.msc + the RDP
+# WMI method) and when.
+
+# A SHA-1 certificate thumbprint is exactly 40 hex characters. Thumbprints read
+# from the listeners are validated against this before being used as a cert-store
+# path, so a malformed value can never reach a provider path.
+function Test-CertThumbprint {
+ param([string]$Thumbprint)
+ return ($Thumbprint -and $Thumbprint -match '^[0-9A-Fa-f]{40}$')
+}
+
+# Resolve a thumbprint to a friendly one-line summary from LocalMachine\My.
+function Get-CertSummaryByThumbprint {
+ param([string]$Thumbprint)
+ if (-not (Test-CertThumbprint -Thumbprint $Thumbprint)) { return $null }
+ $cert = Get-Item -Path ("Cert:\LocalMachine\My\{0}" -f $Thumbprint.ToUpper()) -ErrorAction SilentlyContinue
+ if (-not $cert) { return $null }
+ $days = [int]([math]::Floor(($cert.NotAfter - (Get-Date)).TotalDays))
+ return [PSCustomObject]@{
+ Thumbprint = $cert.Thumbprint
+ Subject = $cert.Subject
+ NotAfter = $cert.NotAfter
+ DaysLeft = $days
+ HasPrivateKey = [bool]$cert.HasPrivateKey
+ }
+}
+
+# Read-only: which certificate is bound to the RDP-Tcp listener and to the WinRM
+# HTTPS listener, with expiry. Makes no changes.
+function Get-ServiceCertificateBindings {
+ $result = [PSCustomObject]@{
+ Available = $true
+ RDPThumbprint = $null
+ RDPCert = $null
+ WinRMThumbprint = $null
+ WinRMCert = $null
+ }
+ # RDP-Tcp listener certificate (TerminalServices CIM).
+ try {
+ $ts = Get-CimInstance -Namespace 'root\cimv2\TerminalServices' -ClassName Win32_TSGeneralSetting -Filter "TerminalName='RDP-Tcp'" -ErrorAction Stop
+ if ($ts -and $ts.SSLCertificateSHA1Hash) {
+ $result.RDPThumbprint = "$($ts.SSLCertificateSHA1Hash)"
+ $result.RDPCert = Get-CertSummaryByThumbprint -Thumbprint $result.RDPThumbprint
+ }
+ } catch {}
+ # WinRM HTTPS listener certificate (WSMan provider) — read-only.
+ try {
+ $listeners = Get-ChildItem -Path 'WSMan:\localhost\Listener\*' -ErrorAction SilentlyContinue
+ foreach ($l in $listeners) {
+ $props = Get-ChildItem -Path $l.PSPath -ErrorAction SilentlyContinue
+ $transport = ($props | Where-Object { $_.Name -eq 'Transport' }).Value
+ if ($transport -eq 'HTTPS') {
+ $tp = ($props | Where-Object { $_.Name -eq 'CertificateThumbprint' }).Value
+ if ($tp) { $result.WinRMThumbprint = "$tp"; $result.WinRMCert = Get-CertSummaryByThumbprint -Thumbprint "$tp" }
+ }
+ }
+ } catch {}
+ return $result
+}
+
+# Render a one-line binding row with expiry colouring.
+function Write-CertBindingRow {
+ param([string]$Label, $Cert, [string]$RawThumbprint)
+ if ($Cert) {
+ $c = if ($Cert.DaysLeft -lt 0) { "Error" } elseif ($Cert.DaysLeft -lt 30) { "Warning" } else { "Success" }
+ $pk = if ($Cert.HasPrivateKey) { "" } else { " [no private key]" }
+ Write-OutputColor " │$(" $Label : $($Cert.Subject) ($($Cert.DaysLeft)d left)$pk".PadRight(72))│" -color $c
+ } elseif ($RawThumbprint) {
+ Write-OutputColor " │$(" $Label : bound to $RawThumbprint (cert not in store)".PadRight(72))│" -color "Warning"
+ } else {
+ Write-OutputColor " │$(" $Label : no certificate bound".PadRight(72))│" -color "Info"
+ }
+}
+
+# Read-only display of the service certificate bindings.
+function Show-CertificateBindings {
+ Clear-Host
+ Write-CenteredOutput "Certificate Binding Audit" -color "Info"
+ $b = Get-ServiceCertificateBindings
+ Write-OutputColor "" -color "Info"
+ Write-OutputColor " ┌────────────────────────────────────────────────────────────────────────┐" -color "Info"
+ Write-OutputColor " │$(" SERVICE CERTIFICATE BINDINGS".PadRight(72))│" -color "Info"
+ Write-OutputColor " ├────────────────────────────────────────────────────────────────────────┤" -color "Info"
+ Write-CertBindingRow -Label "RDP-Tcp" -Cert $b.RDPCert -RawThumbprint $b.RDPThumbprint
+ Write-CertBindingRow -Label "WinRM " -Cert $b.WinRMCert -RawThumbprint $b.WinRMThumbprint
+ Write-OutputColor " └────────────────────────────────────────────────────────────────────────┘" -color "Info"
+ Write-OutputColor "" -color "Info"
+ Write-OutputColor " Read-only audit. To rotate a binding, replace the certificate manually and" -color "Info"
+ Write-OutputColor " re-point the listener (RDS Session Host config / certlm.msc). Automated" -color "Info"
+ Write-OutputColor " rotation is deferred until it can be validated against a live RDP server" -color "Info"
+ Write-OutputColor " without risking lock-out from private-key permissions." -color "Info"
+ Write-PressEnter
+}
+
+# CLI: CertBindingAudit — read-only service-certificate binding posture (JSON-aware).
+function Start-CertBindingAudit {
+ $b = Get-ServiceCertificateBindings
+ if ($script:CLIOutputFormat -eq 'JSON') {
+ Write-Output (@{
+ Tool = $script:ToolFullName; Version = $script:ScriptVersion; Action = 'CertBindingAudit'
+ Timestamp = (Get-Date -Format "yyyy-MM-ddTHH:mm:ss"); Hostname = $env:COMPUTERNAME
+ RDPThumbprint = $b.RDPThumbprint
+ RDPSubject = $(if ($b.RDPCert) { $b.RDPCert.Subject } else { $null })
+ RDPDaysLeft = $(if ($b.RDPCert) { $b.RDPCert.DaysLeft } else { $null })
+ RDPHasPrivateKey = $(if ($b.RDPCert) { $b.RDPCert.HasPrivateKey } else { $null })
+ WinRMThumbprint = $b.WinRMThumbprint
+ WinRMSubject = $(if ($b.WinRMCert) { $b.WinRMCert.Subject } else { $null })
+ WinRMDaysLeft = $(if ($b.WinRMCert) { $b.WinRMCert.DaysLeft } else { $null })
+ } | ConvertTo-Json)
+ }
+ else { Show-CertificateBindings }
+ return $true
+}
+#endregion
diff --git a/README.md b/README.md
index d8c3d42..7327448 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,7 @@
-
+
@@ -37,7 +37,7 @@
---
-RackStack is a menu-driven PowerShell tool that automates everything between "Windows is installed" and "server is in production." Where sconfig gives you 15 options, RackStack gives you 198 CLI actions and 60+ interactive menus covering networking, Hyper-V, SAN/iSCSI, clustering, VM deployment, cloud onboarding, and batch automation, all with undo, transaction rollback, and audit logging.
+RackStack is a menu-driven PowerShell tool that automates everything between "Windows is installed" and "server is in production." Where sconfig gives you 15 options, RackStack gives you 199 CLI actions and 60+ interactive menus covering networking, Hyper-V, SAN/iSCSI, clustering, VM deployment, cloud onboarding, and batch automation, all with undo, transaction rollback, and audit logging.
Built for MSPs, sysadmins, and infrastructure teams who build servers repeatedly and want it done right every time.
@@ -61,7 +61,7 @@ Built for MSPs, sysadmins, and infrastructure teams who build servers repeatedly
**Automation** -- JSON-driven batch mode (24 idempotent steps with transaction rollback), Quick Setup Wizard, configuration export/import, HTML reports, JSON audit logging with rotation
-**Monitoring** -- 198 CLI actions with JSON output for fleet automation, `ServerScore` (unified 0-100 health grade), `HealthDashboard` (all-in-one monitoring endpoint), `ClusterHealthScore`, `StorageHealthScore`, System Center (SCCM/SCOM/WAC) + Azure AD/Intune integration
+**Monitoring** -- 199 CLI actions with JSON output for fleet automation, `ServerScore` (unified 0-100 health grade), `HealthDashboard` (all-in-one monitoring endpoint), `ClusterHealthScore`, `StorageHealthScore`, System Center (SCCM/SCOM/WAC) + Azure AD/Intune integration
**Cloud & Security** -- Azure Arc server onboarding (install the Connected Machine Agent, connect the host to Azure's hybrid management plane via service-principal auth); Microsoft Defender for Endpoint onboarding (activate the built-in EDR sensor against your tenant, with a built-in detection test)
@@ -141,7 +141,7 @@ Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope Process
.\RackStack.ps1
```
-> **`RackStack.ps1`** is the **modular loader** (~130 lines). It dot-sources all 78 modules from `Modules/` and starts the tool. Use this for development -- edit individual module files, then run.
+> **`RackStack.ps1`** is the **modular loader** (~130 lines). It dot-sources all 79 modules from `Modules/` and starts the tool. Use this for development -- edit individual module files, then run.
### Single-File Deployment (Production)
@@ -152,7 +152,7 @@ For production use, generate a monolithic single-file script (~66K lines) that y
.\sync-to-monolithic.ps1
```
-The output is **`RackStack v{version}.ps1`** -- a self-contained single file with all 78 modules baked in (version from `00-Initialization.ps1`). This is the file used to compile the `.exe`.
+The output is **`RackStack v{version}.ps1`** -- a self-contained single file with all 79 modules baked in (version from `00-Initialization.ps1`). This is the file used to compile the `.exe`.
> **Don't confuse the two:** `RackStack.ps1` = modular loader for development. `RackStack v{version}.ps1` = monolithic build for deployment/compilation.
@@ -439,7 +439,7 @@ $report.Issues
**Tiers:** `Light` (minimal, safe for prod), `Standard` (recommended), `Aggressive` (maximum cleanup/debloat).
-### 198 CLI Actions
+### 199 CLI Actions
| Category | Actions |
|----------|---------|
@@ -475,7 +475,7 @@ Run `RackStack.exe -ListActions` or `RackStack.exe -ListActions -OutputFormat JS
```
RackStack/
-├── RackStack.ps1 # Modular loader -- dot-sources 78 modules (dev use)
+├── RackStack.ps1 # Modular loader -- dot-sources 79 modules (dev use)
├── RackStack v{version}.ps1 # Monolithic build -- all modules in one file (deploy/compile)
├── RackStack.exe # Compiled from the monolithic .ps1 via ps2exe
├── defaults.json # Your environment config (gitignored)
@@ -496,7 +496,7 @@ RackStack/
### Module Architecture
-78 modules numbered for load order. Dependencies flow downward.
+79 modules numbered for load order. Dependencies flow downward.
| Range | Category | Highlights |
|---|---|---|
@@ -528,7 +528,7 @@ RackStack/
# Full test suite (5,048 tests, ~4 minutes)
powershell -ExecutionPolicy Bypass -File Tests\Run-Tests.ps1
-# PSScriptAnalyzer (0 errors on all 78 modules + monolithic)
+# PSScriptAnalyzer (0 errors on all 79 modules + monolithic)
powershell -ExecutionPolicy Bypass -File Tests\pssa-check.ps1
# Pre-release validation (parse + PSSA + structure + sync + version + tests)
diff --git a/RackStack.ps1 b/RackStack.ps1
index ea5b979..b51adb3 100644
--- a/RackStack.ps1
+++ b/RackStack.ps1
@@ -3,7 +3,7 @@
RackStack - Modular Loader (Development)
.DESCRIPTION
- This is the MODULAR LOADER -- it dot-sources all 78 modules from the Modules/
+ This is the MODULAR LOADER -- it dot-sources all 79 modules from the Modules/
subfolder and starts RackStack. Use this file for development and testing.
This is NOT the monolithic build. The monolithic single-file version is:
@@ -13,7 +13,7 @@
Environment-specific settings are configured via defaults.json.
.VERSION
- 1.116.0
+ 1.117.0
.NOTES
- Requires Windows Server 2012 R2 or later (or Windows 10/11 for testing)
@@ -115,6 +115,7 @@ $moduleFiles = @(
"75-Compliance.ps1"
"76-SIEMForwarder.ps1"
"77-WindowsAdminCenter.ps1"
+ "78-CertificateAudit.ps1"
)
# Load all modules
diff --git a/RackStack.psd1 b/RackStack.psd1
index 69f8856..066a777 100644
--- a/RackStack.psd1
+++ b/RackStack.psd1
@@ -1,6 +1,6 @@
@{
RootModule = 'RackStack.psm1'
- ModuleVersion = '1.116.0'
+ ModuleVersion = '1.117.0'
GUID = 'c19b8e71-4a35-4f2b-9d06-8a24f7bc0e91'
Author = 'TheAbider'
CompanyName = 'TheAbider'
diff --git a/Tests/Run-Tests.ps1 b/Tests/Run-Tests.ps1
index 3c5c296..5b92ab3 100644
--- a/Tests/Run-Tests.ps1
+++ b/Tests/Run-Tests.ps1
@@ -116,7 +116,7 @@ if (Test-Path $_testInitFile) {
}
}
$monolithicPath = Join-Path (Join-Path $script:ModuleRoot "builds") "$_testToolFullName v$_testScriptVersion.ps1"
-$expectedModuleCount = 78 # 00-77 inclusive
+$expectedModuleCount = 79 # 00-78 inclusive
# ============================================================================
# BANNER
@@ -742,8 +742,8 @@ try {
try {
$firstName = $moduleFiles[0].Name
$lastName = $moduleFiles[-1].Name
- $pass = $firstName -eq "00-Initialization.ps1" -and $lastName -eq "77-WindowsAdminCenter.ps1"
- Write-TestResult "Module range 00-Initialization to 77-WindowsAdminCenter" $pass "First=$firstName, Last=$lastName"
+ $pass = $firstName -eq "00-Initialization.ps1" -and $lastName -eq "78-CertificateAudit.ps1"
+ Write-TestResult "Module range 00-Initialization to 78-CertificateAudit" $pass "First=$firstName, Last=$lastName"
} catch {
Write-TestResult "Module range verification" $false $_.Exception.Message
}
@@ -2158,8 +2158,8 @@ try {
if ($line -match '^\s*#region\s') { $regionStartCount++ }
if ($line -match '^\s*#endregion') { $regionEndCount++ }
}
- Write-TestResult "Monolithic has 77 #region tags" ($regionStartCount -eq 77) "Found: $regionStartCount"
- Write-TestResult "Monolithic has 77 #endregion tags" ($regionEndCount -eq 77) "Found: $regionEndCount"
+ Write-TestResult "Monolithic has 78 #region tags" ($regionStartCount -eq 78) "Found: $regionStartCount"
+ Write-TestResult "Monolithic has 78 #endregion tags" ($regionEndCount -eq 78) "Found: $regionEndCount"
Write-TestResult "Region start/end counts match" ($regionStartCount -eq $regionEndCount) "Starts=$regionStartCount, Ends=$regionEndCount"
} catch {
Write-TestResult "Region count verification" $false $_.Exception.Message
@@ -4490,7 +4490,7 @@ Write-TestResult "README.md exists" (Test-Path $readmePath)
try {
$readmeContent = Get-Content $readmePath -Raw
- Write-TestResult "README: mentions 78 modules" ($readmeContent -match '78 module')
+ Write-TestResult "README: mentions 78 modules" ($readmeContent -match '79 module')
Write-TestResult "README: has batch mode section" ($readmeContent -match 'Batch Mode')
Write-TestResult "README: has testing section" ($readmeContent -match 'Testing')
Write-TestResult "README: has defaults.json example" ($readmeContent -match 'defaults\.json')
@@ -6898,11 +6898,11 @@ try {
# RackStack.ps1 loader includes 62-HyperVReplica.ps1
$loaderContent = Get-Content $loaderPath -Raw
Write-TestResult "RackStack.ps1: loads 62-HyperVReplica.ps1" ($loaderContent -match '62-HyperVReplica\.ps1')
- Write-TestResult "RackStack.ps1: mentions 78 modules" ($loaderContent -match '78 modules')
+ Write-TestResult "RackStack.ps1: mentions 78 modules" ($loaderContent -match '79 modules')
# Module count verification
$moduleCount = (Get-ChildItem -Path $modulesPath -Filter "*.ps1").Count
- Write-TestResult "Module count is 78" ($moduleCount -eq 78) "Found $moduleCount modules"
+ Write-TestResult "Module count is 79" ($moduleCount -eq 79) "Found $moduleCount modules"
# Changelog mentions v1.4.0
$changelogPath = Join-Path $script:ModuleRoot "Changelog.md"
@@ -9233,6 +9233,54 @@ catch {
Write-TestResult "NTP Clock-Tamper Protection Tests" $false $_.Exception.Message
}
+# ============================================================================
+# SECTION 184: CERTIFICATE BINDING AUDIT (v1.117.0, NEW module 78)
+# ============================================================================
+# Read-only audit module. Automated rotation was deliberately deferred after an
+# adversarial review surfaced an RDP lock-out risk (a self-signed cert's private
+# key is not readable by NETWORK SERVICE by default) plus uncertain CIM
+# writability of SSLCertificateSHA1Hash — neither testable without a live RDP
+# server. This section therefore asserts the module is genuinely READ-ONLY.
+Write-SectionHeader "SECTION 184: CERTIFICATE BINDING AUDIT (78-CertificateAudit)"
+
+try {
+ $crPath = "$modulesPath\78-CertificateAudit.ps1"
+ Write-TestResult "78-CertAudit: module file exists" (Test-Path $crPath)
+ $crC = Get-Content $crPath -Raw
+ Write-TestResult "78-CertAudit: Get-ServiceCertificateBindings exists" ($crC -match 'function\s+Get-ServiceCertificateBindings\b')
+ Write-TestResult "78-CertAudit: Get-CertSummaryByThumbprint exists" ($crC -match 'function\s+Get-CertSummaryByThumbprint\b')
+ Write-TestResult "78-CertAudit: Show-CertificateBindings exists" ($crC -match 'function\s+Show-CertificateBindings\b')
+ Write-TestResult "78-CertAudit: Start-CertBindingAudit exists" ($crC -match 'function\s+Start-CertBindingAudit\b')
+ # Reports both RDP and WinRM listener bindings.
+ Write-TestResult "78-CertAudit: reports RDP + WinRM bindings" ($crC -match 'Win32_TSGeneralSetting' -and $crC -match "WSMan:\\localhost\\Listener")
+ # Surfaces HasPrivateKey (the lock-out signal that justified deferring rotation).
+ Write-TestResult "78-CertAudit: reports HasPrivateKey" ($crC -match 'HasPrivateKey')
+ # Thumbprints validated as 40 hex before use as a cert-store path.
+ Write-TestResult "78-CertAudit: validates thumbprint (40 hex)" ($crC -match "match '\^\[0-9A-Fa-f\]\{40\}\`$'")
+ # READ-ONLY: no privileged writes anywhere in the module.
+ Write-TestResult "78-CertAudit: is read-only (no Set-CimInstance)" (-not ($crC -match 'Set-CimInstance'))
+ Write-TestResult "78-CertAudit: is read-only (no New-SelfSignedCertificate)" (-not ($crC -match 'New-SelfSignedCertificate'))
+ Write-TestResult "78-CertAudit: does not mutate WinRM listener" (-not ($crC -match 'New-Item[^\r\n]*WSMan|Set-Item[^\r\n]*WSMan|Remove-Item[^\r\n]*Listener'))
+ Write-TestResult "78-CertAudit: no PFX/password handling" (-not ($crC -match 'Import-PfxCertificate|AsSecureString|-Password'))
+ Write-TestResult "78-CertAudit: documents deferred rotation" ($crC -match 'rotation is deferred|deferred to a future release')
+ Write-TestResult "78-CertAudit: CertBindingAudit JSON-aware" ($crC -match "Start-CertBindingAudit[\s\S]{0,300}CLIOutputFormat -eq 'JSON'")
+ # Module + menu + CLI wiring.
+ $loaderC = Get-Content (Join-Path $script:ModuleRoot "RackStack.ps1") -Raw
+ Write-TestResult "RackStack.ps1: loads 78-CertificateAudit" ($loaderC -match '78-CertificateAudit\.ps1')
+ $menuC = Get-Content "$modulesPath\48-MenuDisplay.ps1" -Raw
+ Write-TestResult "48-MenuDisplay: Security menu [12] Certificate Binding Audit" ($menuC -match '\[12\]\s*Certificate Binding Audit')
+ $runnerC = Get-Content "$modulesPath\49-MenuRunner.ps1" -Raw
+ Write-TestResult "49-MenuRunner: Security menu case 12 wired" ($runnerC -match '"12"\s*\{\s*Show-CertificateBindings')
+ Write-TestResult "49-MenuRunner: Security invalid msg bumped to 1-12" ($runnerC -match 'Enter 1-12 or B')
+ $crEntry = Get-Content "$modulesPath\50-EntryPoint.ps1" -Raw
+ Write-TestResult "50-EntryPoint: CertBindingAudit dispatch case" ($crEntry -match "'CertBindingAudit'\s*\{")
+ $crHeader = Get-Content (Join-Path $script:ModuleRoot "Header.ps1") -Raw
+ Write-TestResult "Header.ps1: CertBindingAudit in -Action ValidateSet" ($crHeader -match "'CertBindingAudit'")
+}
+catch {
+ Write-TestResult "Certificate Binding Audit Tests" $false $_.Exception.Message
+}
+
# ============================================================================
# SECTION 174: DOCUMENTATION FRESHNESS (counts must match the codebase)
# ============================================================================
@@ -13244,7 +13292,7 @@ try {
# Action list in -ListActions block has 160 entries
$listBlock = [regex]::Match($ep5, '\$actionList = @\([\s\S]*?\)[\s\S]{0,50}CLIOutputFormat').Value
$listActionCount = @([regex]::Matches($listBlock, "Action\s*=\s*'")).Count
- Write-TestResult "50-EntryPoint: action list has 198 entries" ($listActionCount -eq 198) "Found $listActionCount"
+ Write-TestResult "50-EntryPoint: action list has 199 entries" ($listActionCount -eq 199) "Found $listActionCount"
} catch {
Write-TestResult "v1.91.0 Tests" $false $_.Exception.Message
}
@@ -13277,7 +13325,7 @@ try {
# Action list count (should be 167 now)
$listBlock2 = [regex]::Match($ep6, '\$actionList = @\([\s\S]*?\)[\s\S]{0,50}CLIOutputFormat').Value
$actionCount2 = @([regex]::Matches($listBlock2, "Action\s*=\s*'")).Count
- Write-TestResult "50-EntryPoint: action list has 198 entries" ($actionCount2 -eq 198) "Found $actionCount2"
+ Write-TestResult "50-EntryPoint: action list has 199 entries" ($actionCount2 -eq 199) "Found $actionCount2"
} catch {
Write-TestResult "v1.92.0 Tests" $false $_.Exception.Message
}
@@ -13303,7 +13351,7 @@ try {
# Action count updated
$listBlock3 = [regex]::Match($ep7, '\$actionList = @\([\s\S]*?\)[\s\S]{0,50}CLIOutputFormat').Value
$actionCount3 = @([regex]::Matches($listBlock3, "Action\s*=\s*'")).Count
- Write-TestResult "50-EntryPoint: action list has 198 entries" ($actionCount3 -eq 198) "Found $actionCount3"
+ Write-TestResult "50-EntryPoint: action list has 199 entries" ($actionCount3 -eq 199) "Found $actionCount3"
} catch {
Write-TestResult "v1.93.0 Tests" $false $_.Exception.Message
}
@@ -13341,7 +13389,7 @@ try {
# Action list count
$listBlock4 = [regex]::Match($ep8, '\$actionList = @\([\s\S]*?\)[\s\S]{0,50}CLIOutputFormat').Value
$actionCount4 = @([regex]::Matches($listBlock4, "Action\s*=\s*'")).Count
- Write-TestResult "50-EntryPoint: action list has 198 entries" ($actionCount4 -eq 198) "Found $actionCount4"
+ Write-TestResult "50-EntryPoint: action list has 199 entries" ($actionCount4 -eq 199) "Found $actionCount4"
} catch {
Write-TestResult "v1.94.1 Tests" $false $_.Exception.Message
}
diff --git a/dist/chocolatey/rackstack.nuspec b/dist/chocolatey/rackstack.nuspec
index 3099311..d649816 100644
--- a/dist/chocolatey/rackstack.nuspec
+++ b/dist/chocolatey/rackstack.nuspec
@@ -18,7 +18,7 @@
windows-server hyper-v iscsi clustering powershell sysadmin automation msp admin-tools
PowerShell automation toolkit for configuring Windows Server hosts
-RackStack is a menu-driven PowerShell tool that automates everything between "Windows is installed" and "server is in production." 198 CLI actions and 60+ interactive menus covering networking, Hyper-V, SAN/iSCSI, clustering, VM deployment, and batch automation, all with undo, transaction rollback, and audit logging.
+RackStack is a menu-driven PowerShell tool that automates everything between "Windows is installed" and "server is in production." 199 CLI actions and 60+ interactive menus covering networking, Hyper-V, SAN/iSCSI, clustering, VM deployment, and batch automation, all with undo, transaction rollback, and audit logging.
Built for MSPs, sysadmins, and infrastructure teams who build servers repeatedly and want it done right every time.
diff --git a/dist/scoop/rackstack.json b/dist/scoop/rackstack.json
index 71c087a..6deb4a3 100644
--- a/dist/scoop/rackstack.json
+++ b/dist/scoop/rackstack.json
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/ScoopInstaller/Scoop/master/schema.json",
"version": "0.0.0",
- "description": "PowerShell automation toolkit for configuring Windows Server hosts — Hyper-V virtualization hosts, failover cluster nodes, iSCSI storage clients, Active Directory members, and standalone servers. Ships as a code-signed EXE plus a PowerShell Gallery wrapper module exposing 198 structured CLI actions.",
+ "description": "PowerShell automation toolkit for configuring Windows Server hosts — Hyper-V virtualization hosts, failover cluster nodes, iSCSI storage clients, Active Directory members, and standalone servers. Ships as a code-signed EXE plus a PowerShell Gallery wrapper module exposing 199 structured CLI actions.",
"homepage": "https://github.com/TheAbider/RackStack",
"license": "MIT",
"url": "https://github.com/TheAbider/RackStack/releases/download/v0.0.0/RackStack.exe",
diff --git a/dist/winget/1.99.1/TheAbider.RackStack.locale.en-US.yaml b/dist/winget/1.99.1/TheAbider.RackStack.locale.en-US.yaml
index 5342242..afa0684 100644
--- a/dist/winget/1.99.1/TheAbider.RackStack.locale.en-US.yaml
+++ b/dist/winget/1.99.1/TheAbider.RackStack.locale.en-US.yaml
@@ -13,7 +13,7 @@ Copyright: Copyright (c) 2026 TheAbider
ShortDescription: PowerShell automation toolkit for configuring Windows Server hosts.
Description: |-
RackStack is a menu-driven PowerShell tool that automates everything between
- "Windows is installed" and "server is in production." It provides 198 CLI
+ "Windows is installed" and "server is in production." It provides 199 CLI
actions and 60+ interactive menus covering networking, Hyper-V, SAN/iSCSI,
clustering, VM deployment, cloud onboarding, and batch automation, all with
undo, transaction rollback, and audit logging. Built for MSPs, sysadmins,
diff --git a/sync-to-monolithic.ps1 b/sync-to-monolithic.ps1
index 03c7e45..68f0d1a 100644
--- a/sync-to-monolithic.ps1
+++ b/sync-to-monolithic.ps1
@@ -1,4 +1,4 @@
-param(
+param(
[switch]$DryRun
)
@@ -42,7 +42,7 @@ if (-not (Test-Path $monoPath)) {
$initModuleFiles = Get-ChildItem $modulesDir -Filter "*.ps1" | Sort-Object Name
# Verify expected module count
- $expectedModuleCount = 78
+ $expectedModuleCount = 79
$actualModuleCount = @($initModuleFiles).Count
if ($actualModuleCount -ne $expectedModuleCount) {
Write-Warning "Expected $expectedModuleCount modules but found $actualModuleCount"
@@ -120,7 +120,7 @@ Write-Host "Monolithic: $($monoLines.Count) lines"
$moduleFiles = Get-ChildItem $modulesDir -Filter "*.ps1" | Sort-Object Name
# Verify expected module count
-$expectedModuleCount = 78
+$expectedModuleCount = 79
$actualModuleCount = @($moduleFiles).Count
if ($actualModuleCount -ne $expectedModuleCount) {
Write-Warning "Expected $expectedModuleCount modules but found $actualModuleCount"