From f172d0051486a9f0fd0bd3fd5ebbdc50e965b695 Mon Sep 17 00:00:00 2001 From: Damien Coles Date: Thu, 19 Feb 2026 17:02:42 +0000 Subject: [PATCH] Initial release: Declarative AD Framework v2.1.0 Infrastructure-as-code framework for Active Directory objects and Group Policy. Sanitized from production deployment for public sharing. --- .gitignore | 14 + CHANGELOG.md | 173 ++ FRAMEWORK.md | 1398 +++++++++++++++++ README.md | 254 +++ ad-objects/Apply-ADBaseline.ps1 | 231 +++ ad-objects/Get-StaleADObjects.ps1 | 202 +++ ad-objects/delegations.ps1 | 46 + ad-objects/groups.ps1 | 20 + ad-objects/lib/ADCore.ps1 | 55 + ad-objects/lib/ADDelegation.ps1 | 232 +++ ad-objects/lib/ADGroup.ps1 | 113 ++ ad-objects/lib/ADHelper.ps1 | 15 + ad-objects/lib/ADOrganizationalUnit.ps1 | 56 + ad-objects/lib/ADPasswordPolicy.ps1 | 224 +++ ad-objects/lib/ADUser.ps1 | 200 +++ ad-objects/ous.ps1 | 32 + ad-objects/password-policies.ps1 | 37 + ad-objects/users.ps1 | 47 + gpo/Apply-DSCBaseline.ps1 | 92 ++ gpo/Apply-GPOBaseline.ps1 | 376 +++++ gpo/Get-UnmanagedGPOs.ps1 | 135 ++ gpo/Restore-GPOBaseline.ps1 | 78 + gpo/admins-01/README.md | 41 + gpo/admins-01/settings.ps1 | 98 ++ gpo/adminworkstations-01/Install-RSAT.ps1 | 45 + gpo/adminworkstations-01/README.md | 111 ++ gpo/adminworkstations-01/WDACPolicy.xml | 169 ++ gpo/adminworkstations-01/settings.ps1 | 470 ++++++ .../DefaultDCPolicy.ps1 | 141 ++ gpo/default-domain-controller/settings.ps1 | 154 ++ gpo/default-domain/DefaultDomainPolicy.ps1 | 118 ++ gpo/default-domain/settings.ps1 | 53 + gpo/lib/GPOAppLocker.ps1 | 272 ++++ gpo/lib/GPOAudit.ps1 | 217 +++ gpo/lib/GPOBackup.ps1 | 214 +++ gpo/lib/GPOCore.ps1 | 273 ++++ gpo/lib/GPOFirewall.ps1 | 303 ++++ gpo/lib/GPOFolderRedirection.ps1 | 215 +++ gpo/lib/GPOHelper.ps1 | 22 + gpo/lib/GPOPermissions.ps1 | 351 +++++ gpo/lib/GPOPolicy.ps1 | 377 +++++ gpo/lib/GPOPreferences.ps1 | 837 ++++++++++ gpo/lib/GPOScripts.ps1 | 271 ++++ gpo/lib/GPOWdac.ps1 | 181 +++ gpo/lib/GPOWmiFilter.ps1 | 190 +++ gpo/servers-01/README.md | 115 ++ gpo/servers-01/settings.ps1 | 357 +++++ gpo/users-01/README.md | 38 + gpo/users-01/settings.ps1 | 133 ++ gpo/workstations-01/README.md | 96 ++ gpo/workstations-01/settings.ps1 | 288 ++++ 51 files changed, 10180 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 FRAMEWORK.md create mode 100644 README.md create mode 100644 ad-objects/Apply-ADBaseline.ps1 create mode 100644 ad-objects/Get-StaleADObjects.ps1 create mode 100644 ad-objects/delegations.ps1 create mode 100644 ad-objects/groups.ps1 create mode 100644 ad-objects/lib/ADCore.ps1 create mode 100644 ad-objects/lib/ADDelegation.ps1 create mode 100644 ad-objects/lib/ADGroup.ps1 create mode 100644 ad-objects/lib/ADHelper.ps1 create mode 100644 ad-objects/lib/ADOrganizationalUnit.ps1 create mode 100644 ad-objects/lib/ADPasswordPolicy.ps1 create mode 100644 ad-objects/lib/ADUser.ps1 create mode 100644 ad-objects/ous.ps1 create mode 100644 ad-objects/password-policies.ps1 create mode 100644 ad-objects/users.ps1 create mode 100644 gpo/Apply-DSCBaseline.ps1 create mode 100644 gpo/Apply-GPOBaseline.ps1 create mode 100644 gpo/Get-UnmanagedGPOs.ps1 create mode 100644 gpo/Restore-GPOBaseline.ps1 create mode 100644 gpo/admins-01/README.md create mode 100644 gpo/admins-01/settings.ps1 create mode 100644 gpo/adminworkstations-01/Install-RSAT.ps1 create mode 100644 gpo/adminworkstations-01/README.md create mode 100644 gpo/adminworkstations-01/WDACPolicy.xml create mode 100644 gpo/adminworkstations-01/settings.ps1 create mode 100644 gpo/default-domain-controller/DefaultDCPolicy.ps1 create mode 100644 gpo/default-domain-controller/settings.ps1 create mode 100644 gpo/default-domain/DefaultDomainPolicy.ps1 create mode 100644 gpo/default-domain/settings.ps1 create mode 100644 gpo/lib/GPOAppLocker.ps1 create mode 100644 gpo/lib/GPOAudit.ps1 create mode 100644 gpo/lib/GPOBackup.ps1 create mode 100644 gpo/lib/GPOCore.ps1 create mode 100644 gpo/lib/GPOFirewall.ps1 create mode 100644 gpo/lib/GPOFolderRedirection.ps1 create mode 100644 gpo/lib/GPOHelper.ps1 create mode 100644 gpo/lib/GPOPermissions.ps1 create mode 100644 gpo/lib/GPOPolicy.ps1 create mode 100644 gpo/lib/GPOPreferences.ps1 create mode 100644 gpo/lib/GPOScripts.ps1 create mode 100644 gpo/lib/GPOWdac.ps1 create mode 100644 gpo/lib/GPOWmiFilter.ps1 create mode 100644 gpo/servers-01/README.md create mode 100644 gpo/servers-01/settings.ps1 create mode 100644 gpo/users-01/README.md create mode 100644 gpo/users-01/settings.ps1 create mode 100644 gpo/workstations-01/README.md create mode 100644 gpo/workstations-01/settings.ps1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3ab343c --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Temporary credential files (ACL-locked, contain plaintext passwords) +ad-objects/.credentials/ + +# Compiled DSC MOF files +gpo/Output/ + +# GPO backups (local snapshots, not committed) +gpo/backups/ + +# JetBrains IDE +.idea/ + +# Claude Code +.claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..469221c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,173 @@ +# Changelog + +## [2.1.0] - 2026-02-14 + +AD objects subsystem refactored into modular architecture matching the GPO pattern. New features for password policies, user properties, group protection, and stale object detection. + +### AD Objects -- Modular Architecture + +Refactored `ADHelper.ps1` from a monolithic library (606 lines) into a loader that dot-sources 6 specialized modules: + +- **ADCore.ps1**: CSPRNG password generation (Get-CryptoRandomInt, New-RandomPassword) +- **ADOrganizationalUnit.ps1**: OU ensure/compare +- **ADGroup.ps1**: Security group ensure/compare with accidental-deletion protection +- **ADUser.ps1**: User account ensure/compare with optional property management +- **ADDelegation.ps1**: OU delegation ACLs (schema GUIDs, ACE generation, bitwise subset checking) +- **ADPasswordPolicy.ps1**: Fine-grained password policy (PSO) ensure/compare + +### Group Protection + +Security groups now get `ProtectedFromAccidentalDeletion = $true` on creation. Existing unprotected groups are remediated on apply. Drift detection in TestOnly mode. + +### Extended User Properties + +User definitions support optional AD attributes (Description, Title, Department, Mail, etc.) via a `-Properties` hashtable. Core schema keys are explicit parameters; everything else flows through Properties. Works for both new user creation and existing user updates. + +### Fine-Grained Password Policies (PSOs) + +New `password-policies.ps1` definition file with two admin-tier policies: + +- **PSO-MasterAdmins**: 16-char minimum, 30-day max age, 48 history, 3-attempt lockout (precedence 10) +- **PSO-DelegatedAdmins**: 12-char minimum, 42-day max age, 24 history, 5-attempt lockout (precedence 20) + +Both override the Default Domain Policy for their linked groups. Property-level drift detection and full AppliesTo group linkage sync. + +### Stale Object Detection + +New `Get-StaleADObjects.ps1` read-only reporting script. Scans managed OUs for: + +- Stale user accounts (no login in N days, default 90) +- Unexpectedly disabled accounts (not intentionally disabled in definitions) +- Empty security groups (zero members) +- Unmanaged users/groups (in managed OUs but not in definition files) +- Pending credential files with age + +### Documentation + +- Updated FRAMEWORK.md with modular AD architecture, PSO definition format, extended user properties +- Updated CLAUDE.md and README.md with new repo structure and features +- Updated function pairs table and dependency ordering + +--- + +## [2.0.0] - 2026-02-14 + +Major expansion of the GPO framework. Modular library architecture, 8 new subsystems, and hardening policies deployed across all GPOs. + +### GPO Framework -- Modular Architecture + +Refactored `GPOHelper.ps1` from a monolithic library into a loader that dot-sources 12 specialized modules: + +- **GPOCore.ps1**: SYSVOL paths, version bump, extension GUIDs, DSC helpers +- **GPOPolicy.ps1**: Security policy (GptTmpl.inf), registry settings, restricted groups +- **GPOPermissions.ps1**: GPO links (with order/enforcement), management permissions, security filtering +- **GPOScripts.ps1**: Startup/shutdown/logon/logoff script deployment to SYSVOL +- **GPOAudit.ps1**: Advanced audit policy (53 subcategories in audit.csv) +- **GPOPreferences.ps1**: Group Policy Preferences XML (10 types -- see below) +- **GPOWmiFilter.ps1**: WMI filter creation and GPO linking +- **GPOBackup.ps1**: Pre-apply backup with timestamped snapshots, restore via `Restore-GPOBaseline.ps1` +- **GPOFirewall.ps1**: Windows Firewall rules (`Open-NetGPO` session) and profile management +- **GPOAppLocker.ps1**: AppLocker policy management via `Set-AppLockerPolicy -LDAP` +- **GPOWdac.ps1**: WDAC policy deployment (.xml auto-converted to .p7b via `ConvertFrom-CIPolicy`) +- **GPOFolderRedirection.ps1**: Folder redirection via fdeploy1.ini (12 supported folders) + +### GPO Preferences (10 types) + +- ScheduledTasks, DriveMaps, EnvironmentVariables, Services (from 1.0) +- **Printers**: Shared printer mapping with default/skip-local options +- **Shortcuts**: Desktop/Start Menu shortcuts (URL, filesystem, shell) +- **Files**: File copy/replace from UNC or local paths +- **NetworkShares**: Local share creation with permissions +- **RegistryItems**: GPP registry items with action modes (distinct from Administrative Templates) +- **LocalUsersAndGroups**: Additive local group membership management (ADD/REMOVE without full replace) +- All types support Item-Level Targeting (ILT) filters + +### GPO Operations + +- **Restore-GPOBaseline.ps1**: List and restore GPO backups by name and timestamp +- **Get-UnmanagedGPOs.ps1**: Discover orphan GPOs in AD not managed by the framework +- **Automatic backups**: Every apply creates timestamped snapshots (SYSVOL + AD attributes), 5 retained per GPO +- **GPO status management**: `DisableUserConfiguration` / `DisableComputerConfiguration` keys + +### New GPO -- Servers-01 + +- Linked to ExampleServers OU with WMI filter (ProductType = 3) +- Full audit (30 advanced audit subcategories), PowerShell transcription + module logging +- Command-line in process creation events, 256 MB security log +- Firewall: default-deny inbound, allow WinRM/RDP/ICMP/SMB +- GPP LocalUsersAndGroups: MasterAdmins added to Remote Desktop Users + +### Hardening Deployed to Existing GPOs + +- **Firewall profiles + rules**: Servers-01, AdminWorkstations-01, Workstations-01 (default-deny inbound, allow management traffic) +- **Advanced audit policy**: Servers-01 (30 subcategories), AdminWorkstations-01 (27 subcategories including DPAPI) +- **AppLocker audit mode**: Workstations-01 and AdminWorkstations-01 (Exe/Msi/Script collections, Microsoft-signed + Program Files + Windows + admin unrestricted) +- **WDAC audit mode**: AdminWorkstations-01 (AllowMicrosoft baseline -- all Microsoft root CAs, WHQL drivers, multiple policy format for future supplemental policies) + +### Documentation + +- **FRAMEWORK.md**: Complete developer reference -- architecture, ensure/compare pattern, all 15 setting types with format documentation, encoding guide, how-to recipes +- Updated README.md with GPO capabilities table, full repo structure, Servers-01 +- Updated CLAUDE.md with 12-module library structure + +### Bug Fixes + +- AppLocker XML element names must match rule type (FilePathRule, FileHashRule, not always FilePublisherRule) +- `Get-NetFirewallRule` uses `-PolicyStore` not `-GPOSession` for reading GPO firewall rules +- `Get-AppLockerPolicy -Domain` is a SwitchParameter (flag), not a string parameter +- XML comments cannot contain `--` (double hyphen) -- .NET XmlSerializer strictly enforces this + +--- + +## [1.0.0] - 2026-02-13 + +First stable release. Full infrastructure-as-code coverage for the example.internal domain. + +### AD Object Management + +- **Apply-ADBaseline.ps1**: Idempotent orchestration for OUs, security groups, and user accounts +- **ADHelper.ps1**: Shared functions -- CSPRNG password generation, OU/group/user ensure and compare +- **Credential handoff**: New user passwords saved to ACL-locked files, never printed to console +- **Stale credential warnings**: Files older than 24 hours trigger a warning banner +- **Dependency ordering**: OUs -> groups -> users -> membership sync + +### Organizational Units + +- ExampleUsers, ExampleWorkstations, ExampleServers, ExampleAdmins, ExampleAdminWorkstations + +### Security Groups and Delegation + +- **MasterAdmins**: Full Control on all managed OUs, GPO edit rights (self-healing) +- **DelegatedAdmins**: Scoped helpdesk in ExampleUsers (password reset, user properties) +- ACL delegation automated via `delegations.ps1` (Ensure/Compare pattern with AD schema GUIDs) + +### Group Policy + +- **Apply-GPOBaseline.ps1**: Declarative GPO management -- security policy, registry settings, links, security filtering, management permissions +- **GPOHelper.ps1**: SYSVOL read/write, GptTmpl.inf parsing, GPO versioning, permission management +- **-GpUpdate switch**: Optional `gpupdate /force` after applying +- **-TestOnly mode**: Drift detection across all GPO settings without changes +- **Self-healing permissions**: MasterAdmins edit rights enforced on every run + +### GPO Policies + +- **Default Domain Policy**: Password (7-char min, 42-day max, 24 history), lockout (5 attempts, 30-min), Kerberos (10-hour TGT) +- **Default Domain Controllers Policy**: 25 user rights assignments, SMB/LDAP signing, secure channel encryption +- **Admins-01**: 10-min session lock, PowerShell script block logging + transcription, taskbar cleanup +- **Users-01**: Desktop lockdown (regedit, cmd, Run disabled), DelegatedAdmins exempted via deny security filtering +- **Workstations-01**: Full audit, autorun disabled, Windows Update 3 AM daily, NLA required, log sizing +- **AdminWorkstations-01**: Enhanced PAW -- all audit categories, PS transcription + module logging, command-line in 4688 events, 256 MB security log, Defender exclusions for JetBrains, RSAT startup script + +### DSC Compliance + +- **Apply-DscBaseline.ps1**: Second-layer validation of DC local state against GPO definitions +- **Single source of truth**: DSC configs read from settings.ps1, no value duplication +- **Kerberos validation**: Custom Script resource using secedit export (SecurityPolicyDsc doesn't support Kerberos natively) +- **Detailed drift output**: Reports specific non-compliant resources +- **Apply mode safety**: Warning banner + confirmation prompt required + +### Documentation + +- README.md with architecture, workflow, security model, and operations guide +- Per-GPO README files with settings tables and design rationale +- CLAUDE.md for AI assistant context diff --git a/FRAMEWORK.md b/FRAMEWORK.md new file mode 100644 index 0000000..1bc06e4 --- /dev/null +++ b/FRAMEWORK.md @@ -0,0 +1,1398 @@ +# Framework Reference + +Developer reference for extending the declarative-ad-framework infrastructure-as-code framework. For day-to-day operations, see README.md. + +## Architecture + +The framework follows a **declarative definitions + idempotent orchestration** pattern: + +- **Definition files** (`ous.ps1`, `groups.ps1`, `users.ps1`, `delegations.ps1`, `password-policies.ps1`, `settings.ps1`) are pure data. They return PowerShell hashtables or arrays describing desired state. No logic, no side effects. +- **Orchestration scripts** (`Apply-ADBaseline.ps1`, `Apply-GPOBaseline.ps1`) consume definitions and converge reality to match. They accept `-TestOnly` for read-only drift detection. +- **Helper libraries** (`ADHelper.ps1`, `GPOHelper.ps1` + modules) contain the `Ensure-*` and `Compare-*` functions that do the actual work. + +There are two independent subsystems: + +| Subsystem | Directory | Orchestrator | Helper | Scope | +|---|---|---|---|---| +| AD Objects | `ad-objects/` | `Apply-ADBaseline.ps1` | `lib/ADHelper.ps1` (loader + 6 modules) | OUs, groups, users, delegation ACLs, password policies (PSOs) | +| AD Hygiene | `ad-objects/` | `Get-StaleADObjects.ps1` | *(standalone)* | Stale accounts, orphans, unmanaged objects, pending credentials | +| GPO Settings | `gpo/` | `Apply-GPOBaseline.ps1` | `lib/GPOHelper.ps1` (loader + 12 modules) | GPO security policy, registry, links, permissions, scripts, audit, preferences, WMI filters, backup, firewall, AppLocker, WDAC, folder redirection | +| GPO Audit | `gpo/` | `Get-UnmanagedGPOs.ps1` | *(standalone)* | Discovers orphan GPOs and framework GPOs not yet created in AD | + +Both work from any domain-joined machine with RSAT installed. Only `Apply-DscBaseline.ps1` (DC compliance validation) requires execution on the domain controller itself. + +### Single Source of Truth + +DSC configurations (`DefaultDomainPolicy.ps1`, `DefaultDCPolicy.ps1`) read directly from the same `settings.ps1` files used by `Apply-GPOBaseline.ps1`. Values are transformed from GptTmpl.inf format into DSC format (SIDs to NTAccount names, registry DWORD strings to integers, etc.) -- never duplicated. + +### Auto-Discovery vs Fixed Loading + +**GPOs** are auto-discovered at runtime: + +```powershell +$gpoDirs = Get-ChildItem -Path $ScriptRoot -Directory | + Where-Object { Test-Path (Join-Path $_.FullName 'settings.ps1') } +``` + +Adding a new GPO requires only creating a directory with a `settings.ps1`. No registration, no manifest, no edits to the orchestration script. + +**AD objects** are loaded from known file paths (`ous.ps1`, `groups.ps1`, etc.) because the set of AD object types is fixed, while the number of GPOs can grow. + +--- + +## The Ensure/Compare Pattern + +Every managed object type has a paired set of functions: + +- **`Ensure-*`**: Idempotent apply. Checks current state, creates if missing, updates if different, skips if correct. Writes status to console: `[CREATED]`, `[UPDATED]`, `[OK]`. +- **`Compare-*`**: Read-only drift detection. Returns an array of diff objects describing differences, or empty/null if no drift. Used by `-TestOnly` mode. + +### Function Pairs + +| Object Type | Ensure | Compare | Library | +|---|---|---|---| +| Organizational Unit | `Ensure-ADOU` | `Compare-ADOU` | ADOrganizationalUnit.ps1 | +| Security Group | `Ensure-ADSecurityGroup` | `Compare-ADSecurityGroup` | ADGroup.ps1 | +| User Account | `Ensure-ADUserAccount` | `Compare-ADUserAccount` | ADUser.ps1 | +| OU Delegation (ACLs) | `Ensure-OUDelegation` | `Compare-OUDelegation` | ADDelegation.ps1 | +| Password Policy (PSO) | `Ensure-ADPasswordPolicy` | `Compare-ADPasswordPolicy` | ADPasswordPolicy.ps1 | +| Security Policy | `Set-GPOSecurityPolicy` | `Compare-GPOSecurityPolicy` | GPOPolicy.ps1 | +| Registry Settings | `Set-GPORegistrySettings` | `Compare-GPORegistrySettings` | GPOPolicy.ps1 | +| GPO Link | `Ensure-GPOLink` | `Compare-GPOLink` | GPOPermissions.ps1 | +| Management Permission | `Ensure-GPOManagementPermission` | `Compare-GPOManagementPermission` | GPOPermissions.ps1 | +| Security Filtering | `Ensure-GPOSecurityFiltering` | `Compare-GPOSecurityFiltering` | GPOPermissions.ps1 | +| Scripts | `Set-GPOScripts` | `Compare-GPOScripts` | GPOScripts.ps1 | +| Advanced Audit Policy | `Set-GPOAdvancedAuditPolicy` | `Compare-GPOAdvancedAuditPolicy` | GPOAudit.ps1 | +| Preferences | `Set-GPOPreferences` | `Compare-GPOPreferences` | GPOPreferences.ps1 | +| WMI Filter | `Ensure-GPOWmiFilter` | `Compare-GPOWmiFilter` | GPOWmiFilter.ps1 | +| GPO Status | `Ensure-GPOStatus` | `Compare-GPOStatus` | GPOCore.ps1 | +| Restricted Groups | `ConvertTo-RestrictedGroupEntries` | *(merged into SecurityPolicy)* | GPOPolicy.ps1 | +| GPO Backup | `Backup-GPOState` / `Restore-GPOState` | `Get-GPOBackups` | GPOBackup.ps1 | +| ILT Filters | `ConvertTo-ILTFilterXml` | *(generated on every apply)* | GPOPreferences.ps1 | +| Firewall Rules | `Set-GPOFirewall` | `Compare-GPOFirewall` | GPOFirewall.ps1 | +| Firewall Profiles | `Set-GPOFirewallProfiles` | `Compare-GPOFirewallProfiles` | GPOFirewall.ps1 | +| AppLocker Policy | `Set-GPOAppLockerPolicy` | `Compare-GPOAppLockerPolicy` | GPOAppLocker.ps1 | +| WDAC Policy | `Set-GPOWdacPolicy` | `Compare-GPOWdacPolicy` | GPOWdac.ps1 | +| Folder Redirection | `Set-GPOFolderRedirection` | `Compare-GPOFolderRedirection` | GPOFolderRedirection.ps1 | + +### Diff Object Format + +Compare functions return objects with properties that describe the drift: + +```powershell +# Security policy diff +@{ Section = 'Event Audit'; Setting = 'AuditLogonEvents'; Current = '1'; Desired = '3' } + +# Registry setting diff +@{ Key = 'HKLM\Software\...'; ValueName = 'MaxSize'; Current = 32768; Desired = 65536 } +``` + +### Self-Healing Permissions + +`Apply-GPOBaseline.ps1` ensures that every group in `$ManagementGroups` (default: `MasterAdmins`) has `GpoEditDeleteModifySecurity` permission on every managed GPO. This runs on every invocation, not just on GPO creation. If someone manually removes MasterAdmins' permission, the next run restores it. + +**Bootstrap requirement**: The first run must be executed as the built-in `Administrator` (Domain Admins) to grant MasterAdmins the initial permissions. After that, MasterAdmins is self-maintaining and Domain Admins membership is never needed for day-to-day operations. + +### Overwrite Semantics + +The framework is **declarative**: the settings files define the complete desired state. However, different setting types have different overwrite behavior: + +| Setting Type | Behavior | Implication | +|---|---|---| +| SecurityPolicy | **Full overwrite** | Regenerates entire GptTmpl.inf. Undeclared settings are removed. | +| RegistrySettings | **Full overwrite** (with cleanup) | Sets declared values via `Set-GPRegistryValue`, then removes stale values under managed keys. Use `-NoCleanup` to opt out. | +| Scripts | **Full overwrite** | Regenerates ini files per scope. Undeclared scripts lose their registration (files remain in SYSVOL but are not executed). | +| AdvancedAuditPolicy | **Full overwrite** | Regenerates entire audit.csv. Undeclared subcategories are removed. | +| Preferences | **Full overwrite** | Regenerates entire XML per type/scope. Undeclared items are removed. | +| RestrictedGroups | **Full overwrite** | Merged into SecurityPolicy [Group Membership]. Subject to SecurityPolicy's full-overwrite behavior. | +| WMIFilter | **Additive** | Creates/updates the filter and links it. Removing the key from settings.ps1 leaves the link in place. | +| FirewallRules | **Full overwrite** | Removes all existing rules, then creates declared rules. | +| FirewallProfiles | **Additive** | Sets declared profile values. Undeclared profiles/values are not touched. | +| AppLockerPolicy | **Full overwrite** | Replaces entire AppLocker policy via `Set-AppLockerPolicy`. | +| WDACPolicy | **Full overwrite** | Replaces SIPolicy.p7b and registry pointer. | +| FolderRedirection | **Full overwrite** | Regenerates entire fdeploy1.ini. Undeclared folders are removed. | + +**Practical implication**: If you remove a setting from `settings.ps1`, it disappears from the GPO on the next apply for all types. SecurityPolicy, Scripts, AdvancedAuditPolicy, and Preferences regenerate their SYSVOL files entirely. RegistrySettings removes stale values under managed keys (any key path that appears in `settings.ps1`). Use `-NoCleanup` to preserve stale registry values during transition periods. + +### Version Bumping + +When SYSVOL files are written directly (SecurityPolicy, Scripts, AdvancedAuditPolicy, Preferences), the GPO version must be incremented so clients re-process the policy. The version number is a packed 32-bit integer: + +- **Upper 16 bits**: user configuration version +- **Lower 16 bits**: machine configuration version + +The orchestrator tracks which scopes were modified during a GPO's processing and bumps the appropriate half once at the end. This avoids multiple increments per apply and correctly tracks which scope changed. Registry settings are exempt because `Set-GPRegistryValue` handles version bumping internally. + +### Console Output Conventions + +All orchestration scripts use consistent color coding: + +| Color | Tags | Meaning | +|---|---|---| +| Green | `[OK]` | Object/setting is in desired state | +| Yellow | `[CREATED]`, `[UPDATED]`, `[GRANTED]` | Change was made | +| Red | `[DRIFT]`, `[MISSING]` | Out of desired state (TestOnly) | +| Magenta | `ACTION REQUIRED`, `WARNING` | Operator attention needed | + +--- + +## GPO Definition Format + +Each GPO lives in its own directory under `gpo/` and must contain a `settings.ps1` that returns a hashtable. + +### Naming Conventions + +- **GPO names**: `-` pattern (e.g., `Admins-01`, `Workstations-01`, `Servers-01`). The number allows multiple GPOs per target in the future. +- **Directory names**: Lowercase-with-hyphens matching the GPO name (e.g., `admins-01/`, `default-domain-controller/`). + +### settings.ps1 Contract + +The file is invoked with `& $settingsPath` and must return a hashtable with these keys: + +| Key | Type | Required | Description | +|---|---|---|---| +| `GPOName` | String | **Yes** | Display name in AD (e.g., `'Workstations-01'`) | +| `Description` | String | No | GPMC description/comment. Displayed in the GPO console and helps prevent manual edits. | +| `LinkTo` | String, Hashtable, Array, or `$null` | No | Target OU. String for simple links, hashtable/array for order + enforcement (see below). `$null` for default GPOs. | +| `SecurityPolicy` | Hashtable | No | GptTmpl.inf sections (see below) | +| `RegistrySettings` | Array | No | Registry value definitions (see below) | +| `SecurityFiltering` | Hashtable | No | Contains `DenyApply` -- array of group names | +| `Scripts` | Hashtable | No | Startup/shutdown/logon/logoff scripts (see below) | +| `AdvancedAuditPolicy` | Hashtable | No | Subcategory-level audit settings (see below) | +| `Preferences` | Hashtable | No | GPP items: 10 types including ScheduledTasks, DriveMaps, Printers, Shortcuts, Files, NetworkShares, RegistryItems, LocalUsersAndGroups (see below) | +| `FirewallRules` | Array | No | Windows Firewall rules (see below) | +| `FirewallProfiles` | Hashtable | No | Firewall profile settings for Domain/Private/Public (see below) | +| `AppLockerPolicy` | Hashtable | No | AppLocker rule collections and enforcement modes (see below) | +| `WDACPolicy` | Hashtable | No | WDAC policy file deployment (see below) | +| `FolderRedirection` | Hashtable | No | User folder redirection paths (see below) | +| `RestrictedGroups` | Hashtable | No | Local group membership control via GptTmpl.inf [Group Membership] (see below) | +| `WMIFilter` | Hashtable | No | WMI filter definition and GPO link (see below) | +| `DisableUserConfiguration` | Bool | No | Disable the User Configuration section of the GPO (default: `$false`) | +| `DisableComputerConfiguration` | Bool | No | Disable the Computer Configuration section of the GPO (default: `$false`) | + +Dynamic code is allowed before the return hashtable. Use this for SID resolution: + +```powershell +# Resolve at evaluation time -- never hardcode SIDs +$masterAdminsSID = (Get-ADGroup -Identity 'MasterAdmins').SID.Value + +@{ + GPOName = 'Default Domain Controllers Policy' + SecurityPolicy = @{ + 'Privilege Rights' = [ordered]@{ + SeRemoteInteractiveLogonRight = "*S-1-5-32-544,*$masterAdminsSID" + } + } +} +``` + +### SecurityPolicy Sections + +SecurityPolicy is a hashtable of hashtables, keyed by GptTmpl.inf section name: + +#### System Access + +Password policy, lockout policy, and account settings. Values are integers or strings. + +```powershell +'System Access' = [ordered]@{ + MinimumPasswordAge = 1 # Days + MaximumPasswordAge = 42 # Days + MinimumPasswordLength = 7 # Characters + PasswordComplexity = 1 # 1=Enabled + PasswordHistorySize = 24 # Previous passwords remembered + LockoutBadCount = 5 # Failed attempts before lockout (0=disabled!) + ResetLockoutCount = 30 # Minutes before counter resets + LockoutDuration = 30 # Minutes locked + EnableGuestAccount = 0 # 0=Disabled +} +``` + +#### Kerberos Policy + +Only effective in the Default Domain Policy (domain-level GPO). + +```powershell +'Kerberos Policy' = [ordered]@{ + MaxTicketAge = 10 # TGT lifetime in hours + MaxRenewAge = 7 # TGT max renewal in days + MaxServiceAge = 600 # Service ticket lifetime in minutes + MaxClockSkew = 5 # Max clock difference in minutes + TicketValidateClient = 1 # 1=Enabled +} +``` + +#### Event Audit + +Legacy audit categories. Values: `0`=None, `1`=Success, `2`=Failure, `3`=Success+Failure. + +```powershell +'Event Audit' = [ordered]@{ + AuditSystemEvents = 3 # Success + Failure + AuditLogonEvents = 3 # Success + Failure + AuditObjectAccess = 2 # Failure only + AuditPrivilegeUse = 2 # Failure only + AuditPolicyChange = 1 # Success only + AuditAccountManage = 3 # Success + Failure + AuditProcessTracking = 0 # None + AuditDSAccess = 0 # None (irrelevant unless DC) + AuditAccountLogon = 3 # Success + Failure +} +``` + +#### Registry Values (Security Options) + +Security options stored in GptTmpl.inf. Format: `'MACHINE\registry\path' = 'type,value'`. + +Type codes: `4`=REG_DWORD, `1`=REG_SZ, `7`=REG_MULTI_SZ. + +```powershell +'Registry Values' = [ordered]@{ + 'MACHINE\Software\Microsoft\Windows\CurrentVersion\Policies\System\InactivityTimeoutSecs' = '4,900' + 'MACHINE\Software\Microsoft\Windows\CurrentVersion\Policies\System\DontDisplayLastUserName' = '4,1' + 'MACHINE\Software\Microsoft\Windows NT\CurrentVersion\Winlogon\DisableCAD' = '4,0' +} +``` + +#### Privilege Rights + +User rights assignments. Trustees listed as `*SID` with asterisk prefix, comma-separated. + +```powershell +'Privilege Rights' = [ordered]@{ + SeRemoteInteractiveLogonRight = "*S-1-5-32-544,*$masterAdminsSID" + # Administrators, MasterAdmins + SeBackupPrivilege = '*S-1-5-32-549,*S-1-5-32-551,*S-1-5-32-544' + # Server Operators, Backup Operators, Administrators +} +``` + +### RegistrySettings Format + +Administrative Template settings applied via `Set-GPRegistryValue`. Each entry is a hashtable with four required keys: + +| Key | Type | Description | +|---|---|---| +| `Key` | String | Registry path. `HKLM\...` for Computer Configuration, `HKCU\...` for User Configuration | +| `ValueName` | String | Registry value name | +| `Type` | String | `DWord`, `String`, `ExpandString`, or `MultiString` | +| `Value` | Mixed | The value to set (integer for DWord, string for String types) | + +```powershell +RegistrySettings = @( + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging' + ValueName = 'EnableScriptBlockLogging' + Type = 'DWord' + Value = 1 + } + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\PowerShell\Transcription' + ValueName = 'OutputDirectory' + Type = 'String' + Value = 'C:\PSlogs\Transcripts' + } +) +``` + +### SecurityFiltering + +Deny Apply prevents a group from receiving the GPO even if they're in the linked OU. + +```powershell +SecurityFiltering = @{ + DenyApply = @('DelegatedAdmins') +} +``` + +Implementation note: this is applied as a raw AD ACL (Deny ACE for the Apply-Group-Policy extended right), not via `Set-GPPermission`, which was found to be unreliable for deny entries. + +### Scripts Format + +Scripts deploys files to SYSVOL and generates the `psscripts.ini` / `scripts.ini` registration files. Supported types: + +| Key | SYSVOL Path | Runs As | +|---|---|---| +| `MachineStartup` | `Machine\Scripts\Startup\` | SYSTEM at boot | +| `MachineShutdown` | `Machine\Scripts\Shutdown\` | SYSTEM at shutdown | +| `UserLogon` | `User\Scripts\Logon\` | User at logon | +| `UserLogoff` | `User\Scripts\Logoff\` | User at logoff | + +Each entry has two keys: + +| Key | Type | Description | +|---|---|---| +| `Source` | String | File path relative to the GPO directory (e.g., `'Install-RSAT.ps1'`) | +| `Parameters` | String | Command-line parameters (empty string for none) | + +`.ps1` files are registered in `psscripts.ini`, all others (`.bat`, `.cmd`, `.exe`) in `scripts.ini`. Both ini files live in the scope's `Scripts\` directory (e.g., `Machine\Scripts\psscripts.ini`) and contain sections for each script type (`[Startup]`, `[Shutdown]`, `[Logon]`, `[Logoff]`). + +The framework automatically: +- Copies script files from the repo to the correct SYSVOL location +- Generates ini files with numbered entries (UTF-16LE encoding) +- Registers the Script CSE GUID (`{42B5FAAE-6536-11D2-AE5A-0000F87571E3}`) in the GPO's extension attributes +- Bumps the GPO version + +```powershell +Scripts = @{ + MachineStartup = @( + @{ + Source = 'Install-RSAT.ps1' + Parameters = '' + } + ) + MachineShutdown = @( + @{ + Source = 'Cleanup.bat' + Parameters = '/silent' + } + ) +} +``` + +Compare mode checks: script file existence in SYSVOL, content hash match (SHA256), and ini file presence. + +### AdvancedAuditPolicy Format + +Subcategory-level audit control (replaces the 9 legacy Event Audit categories with 53+ subcategories). Written to `Machine\Microsoft\Windows NT\Audit\audit.csv` in SYSVOL. + +A GPO can have both `Event Audit` (legacy SecurityPolicy) and `AdvancedAuditPolicy`. When both are present, Advanced Audit takes precedence on the client (standard Windows behavior). + +Valid values: `'Success and Failure'`, `'Success'`, `'Failure'`, `'No Auditing'` + +```powershell +AdvancedAuditPolicy = @{ + # Account Logon + 'Credential Validation' = 'Success and Failure' + 'Kerberos Authentication Service' = 'Success and Failure' + 'Kerberos Service Ticket Operations' = 'Success and Failure' + + # Logon/Logoff + 'Logon' = 'Success and Failure' + 'Logoff' = 'Success' + 'Account Lockout' = 'Success' + 'Special Logon' = 'Success and Failure' + + # Object Access + 'File System' = 'Success and Failure' + 'Registry' = 'Success and Failure' + 'File Share' = 'Success and Failure' + + # Detailed Tracking + 'Process Creation' = 'Success and Failure' + 'Process Termination' = 'Success' + + # Policy Change + 'Audit Policy Change' = 'Success and Failure' + 'Authentication Policy Change' = 'Success' + + # Account Management + 'User Account Management' = 'Success and Failure' + 'Security Group Management' = 'Success and Failure' + + # DS Access (DCs only) + 'Directory Service Access' = 'Success and Failure' + 'Directory Service Changes' = 'Success and Failure' +} +``` + +Subcategory names must match the keys in `$Script:AuditSubcategoryGuid` (GPOAudit.ps1). Unknown subcategories produce a `[WARN]` and are skipped. The full list of 53 supported subcategories is defined in the lookup table, organized by category: System, Logon/Logoff, Object Access, Privilege Use, Detailed Tracking, Policy Change, Account Management, DS Access, Account Logon. + +The framework automatically: +- Generates audit.csv with subcategory GUIDs and numeric setting values +- Writes as UTF-8 with BOM (audit.csv uses UTF-8, unlike GptTmpl.inf which uses UTF-16LE) +- Registers the Audit Policy CSE GUID (`{F3BC9527-C350-4C90-861C-1EC90034520B}`) in extension attributes +- Bumps the GPO version + +### Preferences Format + +Group Policy Preferences (GPP) allow configuring items that aren't part of traditional policy. Ten types are supported: + +#### ScheduledTasks + +| Key | Type | Required | Description | +|---|---|---|---| +| `Name` | String | Yes | Task display name | +| `Action` | String | Yes | `Create`, `Replace`, `Update`, or `Delete` | +| `Command` | String | Yes | Executable path (e.g., `'powershell.exe'`) | +| `Arguments` | String | No | Command-line arguments | +| `RunAs` | String | No | Account to run as (default: `'NT AUTHORITY\System'`) | +| `Trigger` | String | No | `AtStartup` (default), `AtLogon` | +| `Scope` | String | No | `Machine` (default) or `User` | + +#### DriveMaps + +Always User scope. Written to `User\Preferences\Drives\Drives.xml`. + +| Key | Type | Required | Description | +|---|---|---|---| +| `Letter` | String | Yes | Drive letter (e.g., `'Z'`) | +| `Path` | String | Yes | UNC path (e.g., `'\\server\share'`) | +| `Label` | String | No | Drive label shown in Explorer | +| `Action` | String | Yes | `Create`, `Replace`, `Update`, or `Delete` | +| `Reconnect` | Bool | No | Reconnect at logon (`$true`/`$false`) | + +#### EnvironmentVariables + +| Key | Type | Required | Description | +|---|---|---|---| +| `Name` | String | Yes | Variable name | +| `Value` | String | Yes | Variable value | +| `Action` | String | Yes | `Create`, `Replace`, `Update`, or `Delete` | +| `Scope` | String | No | `Machine` (default) or `User` | + +#### Services + +Always Machine scope. Written to `Machine\Preferences\Services\Services.xml`. + +| Key | Type | Required | Description | +|---|---|---|---| +| `ServiceName` | String | Yes | Windows service name (e.g., `'Spooler'`) | +| `StartupType` | String | Yes | `Automatic`, `Manual`, or `Disabled` | +| `Action` | String | Yes | `Create`, `Replace`, `Update`, or `Delete` | + +#### Printers + +Always User scope. Written to `User\Preferences\Printers\Printers.xml`. + +| Key | Type | Required | Description | +|---|---|---|---| +| `Path` | String | Yes | UNC path to shared printer (e.g., `'\\printserver\LaserJet'`) | +| `Action` | String | Yes | `Create`, `Replace`, `Update`, or `Delete` | +| `Default` | Bool | No | Set as default printer | +| `SkipLocal` | Bool | No | Skip if local printer attached | +| `Comment` | String | No | Printer comment | +| `Location` | String | No | Printer location string | + +#### Shortcuts + +| Key | Type | Required | Description | +|---|---|---|---| +| `Name` | String | Yes | Shortcut display name | +| `TargetType` | String | No | `URL`, `FILESYSTEM` (default), or `SHELL` | +| `TargetPath` | String | Yes | Target URL or file path | +| `ShortcutPath` | String | Yes | Where to create the shortcut (e.g., `'%DesktopDir%\IT Help Desk'`) | +| `Action` | String | Yes | `Create`, `Replace`, `Update`, or `Delete` | +| `Scope` | String | No | `User` (default) or `Machine` | +| `Arguments` | String | No | Command-line arguments | +| `StartIn` | String | No | Working directory | +| `Comment` | String | No | Shortcut comment | +| `IconPath` | String | No | Icon file path | +| `IconIndex` | Int | No | Icon index (default: 0) | + +#### Files + +| Key | Type | Required | Description | +|---|---|---|---| +| `FromPath` | String | Yes | Source file path (UNC or local) | +| `TargetPath` | String | Yes | Destination file path | +| `Action` | String | Yes | `Create`, `Replace`, `Update`, or `Delete` | +| `Scope` | String | No | `Machine` (default) or `User` | +| `ReadOnly` | Bool | No | Set read-only attribute | +| `Hidden` | Bool | No | Set hidden attribute | + +#### NetworkShares + +Always Machine scope. Written to `Machine\Preferences\NetworkShares\NetworkShares.xml`. + +| Key | Type | Required | Description | +|---|---|---|---| +| `Name` | String | Yes | Share name | +| `Path` | String | Yes | Local folder path to share | +| `Action` | String | Yes | `Create`, `Replace`, `Update`, or `Delete` | +| `Comment` | String | No | Share comment | +| `AllRegular` | String | No | Default share permissions (e.g., `'READ'`) | +| `AllHidden` | String | No | Hidden share permissions | +| `AllAdminDrive` | String | No | Admin share permissions | +| `LimitUsers` | Int | No | Max concurrent users (0 = unlimited) | + +#### RegistryItems (GPP) + +GPP Registry items -- distinct from `RegistrySettings` (Administrative Templates). These provide finer control (action modes, ILT targeting) but are processed by the GPP CSE rather than the Registry CSE. + +| Key | Type | Required | Description | +|---|---|---|---| +| `Hive` | String | Yes | `HKEY_LOCAL_MACHINE` or `HKEY_CURRENT_USER` | +| `Key` | String | Yes | Registry key path (no hive prefix) | +| `Name` | String | No | Value name (empty = default value) | +| `Type` | String | No | `REG_SZ` (default), `REG_DWORD`, `REG_BINARY`, etc. | +| `Value` | String | No | Value data | +| `Action` | String | Yes | `Create`, `Replace`, `Update`, or `Delete` | +| `Scope` | String | No | `Machine` (default) or `User` | + +#### LocalUsersAndGroups (GPP) + +Always Machine scope. Written to `Machine\Preferences\Groups\Groups.xml`. + +Unlike `RestrictedGroups`, GPP Local Groups supports ADD/REMOVE individual members without replacing the full membership list. Use `RestrictedGroups` for security-critical groups (Administrators); use `LocalUsersAndGroups` for additive membership management. + +| Key | Type | Required | Description | +|---|---|---|---| +| `GroupName` | String | Yes | Local group name (e.g., `'Remote Desktop Users'`) | +| `Action` | String | Yes | `Create`, `Replace`, `Update`, or `Delete` | +| `Members` | Array | No | Array of `@{ Name = '...'; Action = 'ADD' or 'REMOVE' }` entries | +| `DeleteAllUsers` | Bool | No | Remove all existing user members before applying | +| `DeleteAllGroups` | Bool | No | Remove all existing group members before applying | +| `NewName` | String | No | Rename the group | +| `Description` | String | No | Group description | + +Member SIDs are resolved at apply time using `NTAccount.Translate()`. + +#### Item-Level Targeting (Filters) + +Any GPP item can include an optional `Filters` array for conditional targeting. Filters control which computers or users receive the item, evaluated client-side during policy processing. + +```powershell +DriveMaps = @( + @{ + Letter = 'Z' + Path = '\\fileserver\shared' + Label = 'Shared' + Action = 'Replace' + Filters = @( + @{ Type = 'SecurityGroup'; Name = 'EXAMPLE\MasterAdmins' } + ) + } +) +``` + +Supported filter types: + +| Type | Required Keys | Optional Keys | Description | +|---|---|---|---| +| `SecurityGroup` | `Name` | `Bool`, `Not`, `UserContext`, `PrimaryGroup`, `LocalGroup` | Resolves group to SID at apply time | +| `OrgUnit` | `Name` (DN) | `Bool`, `Not` | Target OU distinguished name | +| `Computer` | `Name` | `Bool`, `Not` | NETBIOS computer name | +| `User` | `Name` | `Bool`, `Not` | NETBIOS user name | +| `OperatingSystem` | `Name` | `Bool`, `Not`, `Edition`, `Version` | OS type string (e.g., `'Windows 11 Enterprise'`) | +| `WMI` | `Name`, `Query` | `Bool`, `Not`, `Namespace` | WQL query with display label | + +Common keys for all filter types: + +| Key | Default | Description | +|---|---|---| +| `Bool` | `'AND'` | Logical operator (`AND` or `OR`) combining with other filters | +| `Not` | `$false` | Negate the filter result | + +SecurityGroup names are resolved to SIDs at apply time using `NTAccount.Translate()` -- never hardcode SIDs. Multiple filters are combined using Boolean logic (AND/OR). + +```powershell +Preferences = @{ + ScheduledTasks = @( + @{ + Name = 'Install-RSAT' + Action = 'Replace' + Command = 'powershell.exe' + Arguments = '-ExecutionPolicy Bypass -File C:\Scripts\Install-RSAT.ps1' + RunAs = 'NT AUTHORITY\System' + Trigger = 'AtStartup' + Scope = 'Machine' + } + ) + DriveMaps = @( + @{ + Letter = 'Z' + Path = '\\fileserver.example.internal\shared' + Label = 'Shared Drive' + Action = 'Replace' + Reconnect = $true + } + ) + EnvironmentVariables = @( + @{ + Name = 'COMPANY_NAME' + Value = 'Example Corp' + Action = 'Replace' + Scope = 'Machine' + } + ) + Services = @( + @{ + ServiceName = 'Spooler' + StartupType = 'Disabled' + Action = 'Replace' + } + ) +} +``` + +The framework automatically: +- Generates type-specific XML with proper CLSIDs, UIDs, and timestamps +- Groups items by scope (Machine/User) and writes to the correct SYSVOL directory +- Registers per-type CSE GUIDs in the GPO's extension attributes +- Bumps the GPO version + +Compare mode checks XML file existence and verifies each desired item is present by name. + +### RestrictedGroups Format + +Controls local group membership on target machines via the `[Group Membership]` section in GptTmpl.inf. Uses friendly group/account names -- the framework resolves them to SIDs automatically. + +```powershell +RestrictedGroups = @{ + 'BUILTIN\Administrators' = @{ + Members = @('EXAMPLE\Domain Admins', 'EXAMPLE\MasterAdmins') + } + 'BUILTIN\Remote Desktop Users' = @{ + Members = @('EXAMPLE\MasterAdmins') + Memberof = @() # Optional: groups this group should belong to + } +} +``` + +- `Members`: accounts/groups that should be in the target group. Windows enforces this list exactly -- unauthorized members are removed. +- `Members = @()` or omitting `Members` means "empty the group" (removes all members). +- `Memberof`: optional -- controls which groups the target group belongs to. +- SID resolution failure throws a terminating error with a clear message. + +**Mutually exclusive**: Use `RestrictedGroups` OR `SecurityPolicy['Group Membership']`, not both. The orchestrator throws an error if both are present. + +The RestrictedGroups key is merged into SecurityPolicy before processing, so it follows SecurityPolicy's full-overwrite semantics and triggers a Machine scope version bump. + +### WMIFilter Format + +Declaratively defines WMI filters as AD objects and links them to GPOs. WMI filters are stored in `CN=SOM,CN=WMIPolicy,CN=System` in AD. + +```powershell +WMIFilter = @{ + Name = 'Workstations Only' + Description = 'Targets physical workstations -- not servers or DCs' + Query = "SELECT * FROM Win32_OperatingSystem WHERE ProductType = 1" +} +``` + +| Key | Type | Required | Description | +|---|---|---|---| +| `Name` | String | Yes | Filter display name (must be unique across all GPOs) | +| `Description` | String | No | Filter description | +| `Query` | String | Yes | WQL query evaluated on the client | + +Common WQL queries: + +| Target | Query | +|---|---| +| Workstations only | `SELECT * FROM Win32_OperatingSystem WHERE ProductType = 1` | +| Servers only | `SELECT * FROM Win32_OperatingSystem WHERE ProductType = 3` | +| Windows 11+ | `SELECT * FROM Win32_OperatingSystem WHERE Version LIKE '10.0.2%'` | + +**Shared filters**: If two GPOs reference the same filter name, they share the AD object. Both settings.ps1 files must declare identical Name/Query -- the last apply wins if they differ. + +WMI filter creation/linking is an AD-only operation (no SYSVOL write, no version bump needed). + +### FirewallRules Format + +Windows Firewall rules managed via `Open-NetGPO` / `New-NetFirewallRule -GPOSession` / `Save-NetGPO`. Full overwrite semantics -- all existing rules are removed and replaced with declared rules. + +| Key | Type | Required | Description | +|---|---|---|---| +| `DisplayName` | String | Yes | Rule display name (unique identifier) | +| `Direction` | String | Yes | `Inbound` or `Outbound` | +| `Action` | String | Yes | `Allow` or `Block` | +| `Protocol` | String | No | `TCP`, `UDP`, `ICMPv4`, `ICMPv6`, or number | +| `LocalPort` | String/Array | No | Port(s) or range (e.g., `'80'`, `'80,443'`) | +| `RemotePort` | String/Array | No | Port(s) or range | +| `LocalAddress` | String/Array | No | IP addresses | +| `RemoteAddress` | String/Array | No | IP addresses | +| `Profile` | String | No | `Domain`, `Private`, `Public`, or `Any` | +| `Program` | String | No | Executable path | +| `Enabled` | Bool | No | Default `$true` | +| `Description` | String | No | Rule description | + +```powershell +FirewallRules = @( + @{ + DisplayName = 'Allow ICMP Echo (Inbound)' + Direction = 'Inbound' + Action = 'Allow' + Protocol = 'ICMPv4' + Profile = 'Domain' + } + @{ + DisplayName = 'Allow WinRM (Inbound)' + Direction = 'Inbound' + Action = 'Allow' + Protocol = 'TCP' + LocalPort = '5985' + Profile = 'Domain' + Program = 'System' + } +) +``` + +Firewall rules use GPO session cmdlets that handle version bumping internally -- no manual version bump needed. + +### FirewallProfiles Format + +Controls the default behavior for each firewall profile (Domain/Private/Public). + +```powershell +FirewallProfiles = @{ + Domain = @{ Enabled = $true; DefaultInboundAction = 'Block'; DefaultOutboundAction = 'Allow' } + Private = @{ Enabled = $true; DefaultInboundAction = 'Block'; DefaultOutboundAction = 'Allow' } + Public = @{ Enabled = $true; DefaultInboundAction = 'Block'; DefaultOutboundAction = 'Allow' } +} +``` + +Profile settings are applied via `Set-GPRegistryValue` under `HKLM\Software\Policies\Microsoft\WindowsFirewall\{DomainProfile|PrivateProfile|PublicProfile}`. + +### AppLockerPolicy Format + +Application whitelisting rules organized by collection type. Uses `Set-AppLockerPolicy` / `Get-AppLockerPolicy` with `-LDAP` parameter. + +Rule collections: `Exe`, `Msi`, `Script`, `Appx`, `Dll` + +Each collection has an `EnforcementMode` (`AuditOnly` or `Enabled`) and an array of `Rules`: + +| Key | Type | Required | Description | +|---|---|---|---| +| `Type` | String | Yes | `Publisher`, `Path`, or `Hash` | +| `Action` | String | Yes | `Allow` or `Deny` | +| `User` | String | Yes | NTAccount name (e.g., `'Everyone'`, `'BUILTIN\Administrators'`) | +| `Name` | String | No | Rule display name | +| `Description` | String | No | Rule description | + +Type-specific keys: + +| Rule Type | Keys | +|---|---| +| Publisher | `Publisher`, `Product`, `Binary`, `LowVersion`, `HighVersion` | +| Path | `Path` | +| Hash | `Hash`, `FileName`, `SourceFileLength` | + +```powershell +AppLockerPolicy = @{ + Exe = @{ + EnforcementMode = 'AuditOnly' + Rules = @( + @{ + Type = 'Publisher' + Action = 'Allow' + User = 'Everyone' + Publisher = 'O=MICROSOFT CORPORATION, L=REDMOND, S=WASHINGTON, C=US' + Product = '*' + Binary = '*' + } + @{ + Type = 'Path' + Action = 'Allow' + User = 'BUILTIN\Administrators' + Path = '*' + } + ) + } + Script = @{ + EnforcementMode = 'AuditOnly' + Rules = @( + @{ + Type = 'Path' + Action = 'Allow' + User = 'Everyone' + Path = '%WINDIR%\*' + } + ) + } +} +``` + +AppLocker uses `Set-AppLockerPolicy` which handles registry writes and CSE registration internally. No manual version bump needed. + +### WDACPolicy Format + +Lightweight WDAC (Windows Defender Application Control) policy deployment. Copies a compiled policy to SYSVOL and sets the registry pointer. + +```powershell +WDACPolicy = @{ + PolicyFile = 'WDACPolicy.xml' # Path relative to GPO config dir +} +``` + +| Key | Type | Required | Description | +|---|---|---|---| +| `PolicyFile` | String | Yes | Policy file path relative to the GPO directory. `.xml` files are auto-converted to `.p7b` via `ConvertFrom-CIPolicy`. `.p7b` files are copied directly. | + +The framework: +- Copies/converts the policy to `Machine\Microsoft\Windows\DeviceGuard\SIPolicy.p7b` in SYSVOL +- Sets `HKLM\SOFTWARE\Policies\Microsoft\Windows\DeviceGuard\DeployConfigCIPolicy` = 1 via `Set-GPRegistryValue` + +Compare mode checks registry key presence, .p7b file existence, and file hash. + +### FolderRedirection Format + +Redirects user shell folders (Documents, Desktop, etc.) to network paths. Written to `User\Documents & Settings\fdeploy1.ini` in SYSVOL. User scope only. + +Supported folders: `Desktop`, `Documents`, `Pictures`, `Music`, `Videos`, `Favorites`, `AppDataRoaming`, `Contacts`, `Downloads`, `Links`, `Searches`, `StartMenu` + +| Key | Type | Required | Description | +|---|---|---|---| +| `Path` | String | Yes | Redirect target path (supports `%USERNAME%` variable) | +| `GrantExclusive` | Bool | No | Grant user exclusive rights to the redirected folder | +| `MoveContents` | Bool | No | Move existing contents to the new location | + +```powershell +FolderRedirection = @{ + Documents = @{ + Path = '\\fileserver\users\%USERNAME%\Documents' + GrantExclusive = $true + MoveContents = $true + } + Desktop = @{ + Path = '\\fileserver\users\%USERNAME%\Desktop' + GrantExclusive = $true + MoveContents = $true + } +} +``` + +The framework: +- Generates fdeploy1.ini with folder sections (e.g., `[.Documents]`, `[.Desktop]`) +- Writes as UTF-16LE encoding (same as GptTmpl.inf) +- Registers the Folder Redirection CSE GUID (`{25537BA6-77A8-11D2-9B6C-0000F8080861}`) +- Bumps User scope version + +### GPO Status (Disable Configuration Sections) + +Controls which configuration sections of the GPO are active. Useful for machine-only GPOs (disable User Configuration to speed up processing) or temporarily disabling a section. + +```powershell +DisableUserConfiguration = $true # Disable User Configuration +DisableComputerConfiguration = $false # Keep Computer Configuration active +``` + +| Flags | GpoStatus Value | +|---|---| +| Both `$false` (default) | `AllSettingsEnabled` | +| `DisableUserConfiguration = $true` | `UserSettingsDisabled` | +| `DisableComputerConfiguration = $true` | `ComputerSettingsDisabled` | +| Both `$true` | `AllSettingsDisabled` | + +Settings are still written to SYSVOL even when the section is disabled -- they take effect immediately when re-enabled, matching GPMC behavior. + +Fully backward compatible: omitting both keys defaults to `AllSettingsEnabled`. + +### Link Order and Enforcement + +The `LinkTo` key supports three formats, all backward compatible: + +```powershell +# Format 1: Plain string (original behavior, no order/enforcement management) +LinkTo = 'OU=ExampleUsers,DC=example,DC=internal' + +# Format 2: Single hashtable with order and enforcement +LinkTo = @{ + Target = 'OU=ExampleUsers,DC=example,DC=internal' + Order = 1 + Enforced = $false +} + +# Format 3: Array for multiple links +LinkTo = @( + @{ Target = 'OU=ExampleUsers,DC=example,DC=internal'; Order = 1; Enforced = $false } + @{ Target = 'OU=ExampleWorkstations,DC=example,DC=internal'; Order = 2; Enforced = $true } +) +``` + +| Key | Type | Required | Default | Description | +|---|---|---|---|---| +| `Target` | String | Yes | -- | OU distinguished name | +| `Order` | Int or `$null` | No | `$null` | Link precedence (1 = highest). `$null` = do not manage order. | +| `Enforced` | Bool | No | `$false` | Whether the link is enforced (overrides Block Inheritance) | + +**Backward compatibility**: Plain string format (`LinkTo = 'OU=...'`) defaults to `Order = $null, Enforced = $false` -- identical to previous behavior. Existing settings.ps1 files work without modification. + +### Backup and Restore + +The framework automatically snapshots each GPO's state before applying changes. Backups are stored in `gpo/backups/` (gitignored) with a retention limit of 5 per GPO. + +**Automatic backups**: Every `Apply-GPOBaseline.ps1` run (non-TestOnly) creates a timestamped backup for each GPO before making changes. Use `-NoBackup` to skip. + +**What's captured per backup**: +- SYSVOL directory tree (GptTmpl.inf, audit.csv, psscripts.ini, scripts.ini, GPP XMLs, script files) +- AD attributes: versionNumber, description, gPCMachineExtensionNames, gPCUserExtensionNames, gPCWQLFilter +- Metadata: timestamp, admin account, domain, GPO GUID + +**Listing backups**: + +```powershell +.\Restore-GPOBaseline.ps1 # List all backups +.\Restore-GPOBaseline.ps1 -GPOName 'Admins-01' # List backups for one GPO +``` + +**Restoring**: + +```powershell +.\Restore-GPOBaseline.ps1 -GPOName 'Admins-01' -Timestamp '20260214-153000' +``` + +Restore copies SYSVOL files back, restores AD attributes, and bumps the version to force client reprocessing. After restoring, run `Apply-GPOBaseline.ps1 -TestOnly` to verify the state, then update `settings.ps1` to match the restored state (otherwise the next apply will overwrite the restore). + +### Complete settings.ps1 Example + +```powershell +# Example-01 -- Settings Declaration +# Linked to: OU=ExampleOU,DC=example,DC=internal + +@{ + GPOName = 'Example-01' + Description = 'Example policy -- managed by declarative-ad-framework, do not edit manually' + LinkTo = 'OU=ExampleOU,DC=example,DC=internal' + + SecurityPolicy = @{ + 'System Access' = [ordered]@{ + EnableGuestAccount = 0 + } + 'Event Audit' = [ordered]@{ + AuditSystemEvents = 3 + AuditLogonEvents = 3 + AuditAccountLogon = 3 + } + 'Registry Values' = [ordered]@{ + 'MACHINE\...\InactivityTimeoutSecs' = '4,900' + } + } + + RegistrySettings = @( + @{ + Key = 'HKLM\Software\Policies\...' + ValueName = 'EnableFeature' + Type = 'DWord' + Value = 1 + } + ) + + SecurityFiltering = @{ + DenyApply = @('ExemptGroup') + } + + Scripts = @{ + MachineStartup = @( + @{ + Source = 'Install-RSAT.ps1' + Parameters = '' + } + ) + } + + AdvancedAuditPolicy = @{ + 'Credential Validation' = 'Success and Failure' + 'Logon' = 'Success and Failure' + 'Process Creation' = 'Success and Failure' + } + + RestrictedGroups = @{ + 'BUILTIN\Administrators' = @{ + Members = @('EXAMPLE\Domain Admins', 'EXAMPLE\MasterAdmins') + } + } + + WMIFilter = @{ + Name = 'Workstations Only' + Description = 'Targets physical workstations only' + Query = "SELECT * FROM Win32_OperatingSystem WHERE ProductType = 1" + } + + DisableUserConfiguration = $true + + Preferences = @{ + Services = @( + @{ + ServiceName = 'Spooler' + StartupType = 'Disabled' + Action = 'Replace' + Filters = @( + @{ Type = 'OperatingSystem'; Name = 'Windows Server 2025' } + ) + } + ) + Printers = @( + @{ + Path = '\\printserver\LaserJet' + Action = 'Replace' + Default = $true + } + ) + } + + FirewallRules = @( + @{ + DisplayName = 'Allow WinRM (Inbound)' + Direction = 'Inbound' + Action = 'Allow' + Protocol = 'TCP' + LocalPort = '5985' + Profile = 'Domain' + } + ) + + FirewallProfiles = @{ + Domain = @{ Enabled = $true; DefaultInboundAction = 'Block'; DefaultOutboundAction = 'Allow' } + } +} +``` + +--- + +## AD Object Definition Files + +All definition files live under `ad-objects/` and return arrays of hashtables. + +### ous.ps1 + +```powershell +$domainDN = 'DC=example,DC=internal' + +@( + @{ + Name = 'ExampleUsers' # OU name (creates OU=ExampleUsers,...) + Path = $domainDN # Parent container DN + Description = 'Standard user accounts' # Optional description + } +) +``` + +### groups.ps1 + +```powershell +@( + @{ + Name = 'MasterAdmins' + Path = 'OU=ExampleAdmins,DC=example,DC=internal' + Scope = 'Global' # Global, DomainLocal, or Universal + Description = 'Master administrators' + Members = @('t0admin') # SamAccountNames to sync + } +) +``` + +Members are reconciled in a separate pass (Step 4) after users are created in Step 3. + +### users.ps1 + +```powershell +@( + @{ + SamAccountName = 't0admin' + Name = 'Tier Zero Admin' # Display name + GivenName = 'Tier' + Surname = 'Admin' + Path = 'OU=ExampleAdmins,DC=example,DC=internal' + Enabled = $true + MemberOf = @('MasterAdmins', 'Administrators', 'Group Policy Creator Owners') + Description = 'Tier 0 master admin' # Optional AD properties + Title = 'Master Administrator' + Department = 'IT' + } +) +``` + +Core keys (`SamAccountName`, `Name`, `GivenName`, `Surname`, `Path`, `Enabled`, `MemberOf`) are explicit parameters. All other keys are treated as optional AD properties and passed through a `-Properties` hashtable to `Set-ADUser -Replace` (for existing users) or merged into the `New-ADUser` splat (for new users). + +New users get a CSPRNG password saved to `.credentials/.txt`. See [Credential Handling](#credential-handling). + +### delegations.ps1 + +```powershell +$domainDN = 'DC=example,DC=internal' + +@( + @{ + GroupName = 'MasterAdmins' + TargetOUs = @( + "OU=ExampleUsers,$domainDN" + "OU=ExampleServers,$domainDN" + ) + Rights = 'FullControl' # Single string for simple cases + } + @{ + GroupName = 'DelegatedAdmins' + TargetOUs = @("OU=ExampleUsers,$domainDN") + Rights = @( # Array for granular rights + 'ListContents' + 'ReadAllProperties' + 'ResetPassword' + 'ReadWriteProperty:userAccountControl' + 'ReadWriteProperty:lockoutTime' + 'ReadWriteProperty:displayName' + ) + } +) +``` + +#### Rights Syntax + +| Token | AD Translation | Scope | +|---|---|---| +| `FullControl` | `GenericAll` | OU and all descendants | +| `ListContents` | `ListChildren` | OU itself | +| `ReadAllProperties` | `ReadProperty` (all) | OU itself | +| `ResetPassword` | Extended right `00299570-246d-11d0-a768-00aa006e0529` | Descendant user objects | +| `ReadWriteProperty:` | `ReadProperty` + `WriteProperty` on specific attribute GUID | Descendant user objects | + +Supported attributes for `ReadWriteProperty`: `userAccountControl`, `lockoutTime`, `displayName`, `givenName`, `sn`, `mail`, `telephoneNumber`, `description`. To add more, add the attribute's schema GUID to `$Script:ADAttributeGuid` in `ADDelegation.ps1`. + +--- + +## Dependency Ordering + +### AD Objects (Apply-ADBaseline.ps1) + +``` +Step 1: OUs -- must exist before groups/users can be placed in them +Step 2: Groups -- created WITHOUT members (users may not exist yet) +Step 3: Users -- created with MemberOf (adds user to groups) +Step 4: Membership -- second pass on groups, reconciles Members lists +Step 5: Delegations -- ACLs applied last (groups and OUs must exist) +Step 6: Password Policies -- PSOs linked to groups (groups must exist) +``` + +The two-pass group membership sync is a deliberate design choice. Groups are created in Step 2 without members because the users listed in `Members` may not exist yet (they're created in Step 3). Step 4 does the full reconciliation after all users exist. + +### GPO Settings (Apply-GPOBaseline.ps1) + +Per GPO, processed in this order: + +``` + 1. Create GPO if missing (New-GPO) + 2. Description ($gpo.Description) + 3. GPO status (Ensure-GPOStatus -> gPCOptions) + 4. Pre-apply backup (Backup-GPOState -> gpo/backups/) + 5. Management permissions (Ensure-GPOManagementPermission) + 6. Restricted groups merge (ConvertTo-RestrictedGroupEntries -> SecurityPolicy) + 7. Security policy (Set-GPOSecurityPolicy -> GptTmpl.inf) + 8. Registry settings (Set-GPORegistrySettings -> Set-GPRegistryValue, cleanup stale) + 9. GPO link(s) (Ensure-GPOLink -> New-GPLink / Set-GPLink) +10. Security filtering (Ensure-GPOSecurityFiltering) +11. WMI filter (Ensure-GPOWmiFilter -> AD msWMI-Som object) +12. Scripts (Set-GPOScripts -> SYSVOL copy + psscripts.ini) +13. Advanced audit policy (Set-GPOAdvancedAuditPolicy -> audit.csv) +14. Preferences (Set-GPOPreferences -> GPP XML files) +15. Firewall profiles (Set-GPOFirewallProfiles -> registry values) +16. Firewall rules (Set-GPOFirewall -> Open-NetGPO session) +17. AppLocker policy (Set-GPOAppLockerPolicy -> Set-AppLockerPolicy -LDAP) +18. WDAC policy (Set-GPOWdacPolicy -> SYSVOL .p7b + registry) +19. Folder redirection (Set-GPOFolderRedirection -> fdeploy1.ini) +``` + +GPOs are independent of each other and processed in filesystem order (whatever `Get-ChildItem` returns). + +--- + +## How-To Recipes + +### Adding a New GPO + +1. Create directory: `gpo//` +2. Create `gpo//settings.ps1` returning the standard hashtable (see [GPO Definition Format](#gpo-definition-format)) +3. Create `gpo//README.md` documenting all settings with tables (follow existing READMEs as template) +4. Add a row to the GPO Inventory table in `README.md` +5. Commit, push, pull on DC, run: + +```powershell +.\gpo\Apply-GPOBaseline.ps1 -TestOnly # Shows new GPO as drift +.\gpo\Apply-GPOBaseline.ps1 -GpUpdate # Creates GPO, applies, links +.\gpo\Apply-GPOBaseline.ps1 -TestOnly # All [OK] +``` + +No changes to `Apply-GPOBaseline.ps1` are needed -- auto-discovery handles it. + +### Adding a Setting to an Existing GPO + +1. Edit the GPO's `settings.ps1`: + - For security policy: add to the appropriate section hashtable + - For registry settings: add a new hashtable to the `RegistrySettings` array +2. Update the GPO's `README.md` with the new setting +3. Run `Apply-GPOBaseline.ps1 -TestOnly` to verify the drift, then apply + +### Adding a New User + +1. Add a hashtable to `ad-objects/users.ps1` with all required keys +2. Run `Apply-ADBaseline.ps1` -- creates the user with a CSPRNG password +3. Read the password from `ad-objects/.credentials/.txt` +4. Securely share with the user, then delete the file + +### Adding a New Group + +1. Add a hashtable to `ad-objects/groups.ps1` +2. If the group has members, they must already be defined in `users.ps1` +3. Run `Apply-ADBaseline.ps1` + +### Adding a New OU + +1. Add a hashtable to `ad-objects/ous.ps1` +2. Run `Apply-ADBaseline.ps1` + +### Adding a New Delegation Right + +1. Look up the attribute's schema GUID from [Microsoft AD Schema docs](https://learn.microsoft.com/en-us/windows/win32/adschema/attributes-all) +2. Add the GUID to `$Script:ADAttributeGuid` in `ad-objects/lib/ADHelper.ps1` +3. Use the new `ReadWriteProperty:` token in `delegations.ps1` +4. Run `Apply-ADBaseline.ps1` + +For extended rights, add to `$Script:ADExtendedRight` and create a corresponding token in `ConvertTo-DelegationACEs`. + +### password-policies.ps1 + +Fine-grained password policies (PSOs) override the Default Domain Policy for specific groups. Lower Precedence number = higher priority. + +```powershell +@( + @{ + Name = 'PSO-MasterAdmins' + Description = 'Strict password policy for Tier 0 admin accounts' + Precedence = 10 + MinPasswordLength = 16 + PasswordHistoryCount = 48 + MaxPasswordAge = '30.00:00:00' # TimeSpan string + MinPasswordAge = '1.00:00:00' + ComplexityEnabled = $true + ReversibleEncryptionEnabled = $false + LockoutThreshold = 3 + LockoutDuration = '00:30:00' + LockoutObservationWindow = '00:30:00' + AppliesTo = @('MasterAdmins') # Groups to link + } +) +``` + +| Key | Type | Required | Description | +|---|---|---|---| +| `Name` | String | Yes | PSO display name (unique) | +| `Description` | String | No | PSO description | +| `Precedence` | Int | Yes | Priority (lower = higher priority) | +| `MinPasswordLength` | Int | No | Minimum password length | +| `PasswordHistoryCount` | Int | No | Previous passwords remembered | +| `MaxPasswordAge` | String | No | Maximum password age (TimeSpan: `'days.hours:minutes:seconds'`) | +| `MinPasswordAge` | String | No | Minimum password age | +| `ComplexityEnabled` | Bool | No | Require complexity | +| `ReversibleEncryptionEnabled` | Bool | No | Store passwords with reversible encryption | +| `LockoutThreshold` | Int | No | Failed attempts before lockout | +| `LockoutDuration` | String | No | Lockout duration (TimeSpan) | +| `LockoutObservationWindow` | String | No | Failed-attempt counter reset window (TimeSpan) | +| `AppliesTo` | Array | No | Group SamAccountNames to link the PSO to | + +PSOs are created with `ProtectedFromAccidentalDeletion = $true`. Group linkage is fully synced -- groups not in AppliesTo are unlinked. + +### Adding a New Management Group + +1. Edit `$ManagementGroups` in `gpo/Apply-GPOBaseline.ps1`: + +```powershell +$ManagementGroups = @('MasterAdmins', 'NewGroup') +``` + +2. The group will automatically get `GpoEditDeleteModifySecurity` on every managed GPO on the next run. + +### Adding DSC Validation for a GPO + +1. Create a DSC configuration file in the GPO's directory (e.g., `gpo//MyPolicy.ps1`) +2. Read from the same `settings.ps1` to maintain single source of truth: + +```powershell +$settingsPath = Join-Path $PSScriptRoot 'settings.ps1' +$settings = & $settingsPath +``` + +3. Transform values from GptTmpl.inf format to DSC format (e.g., `'4,1'.Split(',')[1]` to extract the DWORD value) +4. Add the configuration to `Apply-DscBaseline.ps1` + +--- + +## Credential Handling + +When `Apply-ADBaseline.ps1` creates new users: + +1. Password generated using `System.Security.Cryptography.RandomNumberGenerator` (CSPRNG) +2. Saved to `ad-objects/.credentials/.txt` +3. File ACL-locked to the admin who ran the script (inheritance disabled, only current user gets FullControl) +4. Password **never** appears in console output, PowerShell transcripts, or logs +5. Script displays an `ACTION REQUIRED` banner listing pending handoffs +6. Admin reads file, securely shares password, deletes file +7. User must change password on first login (`ChangePasswordAtLogon = $true`) + +Credential files older than 24 hours trigger a `WARNING` banner on every subsequent run, catching forgotten handoffs. + +--- + +## Encoding and Compatibility + +### PowerShell Scripts + +DC01 runs **Windows PowerShell 5.1**. Files must be saved as **UTF-8 with BOM** or use **only ASCII characters**. + +Why: PowerShell 5.1 without a BOM interprets files as Windows-1252 (ANSI). Byte `0x97` (em dash in CP-1252) is treated as a string delimiter, causing cryptic parse errors. Avoid em dashes, smart quotes, and other non-ASCII characters in string literals. + +### GptTmpl.inf (SYSVOL) + +Must be written as **UTF-16LE** (what .NET calls `[System.Text.Encoding]::Unicode`). GPOHelper.ps1 handles this automatically with `[System.IO.File]::WriteAllText`. Writing as UTF-8 or ASCII causes the Group Policy engine to silently ignore the file. + +### psscripts.ini / scripts.ini (SYSVOL) + +Must be written as **UTF-16LE**, same as GptTmpl.inf. These files register startup/shutdown/logon/logoff scripts with the Group Policy Script CSE. + +### audit.csv (SYSVOL) + +Must be written as **UTF-8 with BOM** (unlike GptTmpl.inf which is UTF-16LE). This is the Advanced Audit Policy configuration file at `Machine\Microsoft\Windows NT\Audit\audit.csv`. + +### GPP XML files (SYSVOL) + +Must be written as **UTF-8 with BOM**. These are the Group Policy Preferences XML files (10 types including ScheduledTasks.xml, Drives.xml, Printers.xml, Shortcuts.xml, Files.xml, NetworkShares.xml, Registry.xml, Groups.xml, etc.) in `{Machine|User}\Preferences\\`. + +### GPO Version Bumping + +When writing GptTmpl.inf directly to SYSVOL, the GPO version must be incremented in two places: + +1. The `versionNumber` attribute on the GPO's AD object +2. The `Version=` line in `GPT.INI` on SYSVOL + +The version number encodes user and computer versions in a single 32-bit integer (upper 16 bits = user version, lower 16 bits = computer version). GPOHelper.ps1 handles this automatically. + +--- + +## Gotchas and Edge Cases + +### ACE Merging in Active Directory + +AD automatically merges compatible ACEs on the same security principal. For example, separate `ListChildren` and `ReadProperty` ACEs may be merged into a single ACE with both flags set. The delegation code uses **bitwise subset checking** rather than exact equality: + +```powershell +# Correct: checks if desired rights are a SUBSET of existing rights +($existing.ActiveDirectoryRights -band $DesiredACE.ActiveDirectoryRights) -ne $DesiredACE.ActiveDirectoryRights + +# Wrong: fails when AD merges ACEs +$existing.ActiveDirectoryRights -ne $DesiredACE.ActiveDirectoryRights +``` + +### DSC Parameter Sets + +`Test-DscConfiguration -Path` and `-Detailed` are in **incompatible parameter sets**. To get detailed output, use `-ReferenceConfiguration` pointing to a specific MOF file: + +```powershell +# Correct +Test-DscConfiguration -ReferenceConfiguration "$mofDir\localhost.mof" -Verbose + +# Wrong -- will fail +Test-DscConfiguration -Path $mofDir -Detailed +``` + +### DSC is Test-Only by Design + +`Apply-DscBaseline.ps1` is a read-only compliance validation tool. It compiles DSC configurations and tests local state against them, but never modifies the system. To fix drift detected by DSC, edit `settings.ps1` and run `Apply-GPOBaseline.ps1` -- this keeps the GPO as the single source of truth. + +### Security Filtering Deny + +Deny Apply Group Policy is implemented as a raw AD ACL, not via `Set-GPPermission`. The cmdlet was found to be unreliable for deny entries during development. + +### Account Lockout Threshold + +`LockoutBadCount = 0` **disables** account lockout entirely. This is a common misconfiguration -- always set it to a positive integer (e.g., 5). + +### SID Resolution + +Never hardcode SIDs in `settings.ps1`. If a group is deleted and recreated, it gets a new SID. Resolve SIDs dynamically: + +```powershell +$masterAdminsSID = (Get-ADGroup -Identity 'MasterAdmins').SID.Value +``` + +### TestOnly with Non-Existent GPOs + +When `-TestOnly` encounters a GPO that doesn't exist in AD, it reports `[DRIFT]` and skips to the next GPO with `continue`. You can't compare settings against a GPO that hasn't been created yet. diff --git a/README.md b/README.md new file mode 100644 index 0000000..63b079d --- /dev/null +++ b/README.md @@ -0,0 +1,254 @@ +# Declarative AD Framework -- Infrastructure as Code + +Declarative management of Active Directory objects and Group Policy for Windows Server domains. All configuration is defined in PowerShell data files and applied idempotently via orchestration scripts. + +## Architecture + +``` +Admin Workstation Domain Controller ++--------------------------+ +------------------------------+ +| Edit definitions | git | Pull, test, apply | +| Push to remote | ------> | GPO + AD baseline scripts | ++--------------------------+ +------------------------------+ +``` + +**Two management domains:** + +| Subsystem | Scripts | What it manages | +|---|---|---| +| AD Objects | `ad-objects/Apply-ADBaseline.ps1` | OUs, security groups, user accounts, delegation ACLs, password policies (PSOs) | +| Group Policy | `gpo/Apply-GPOBaseline.ps1` | GPOs via 12 modular libraries (see below) | +| DC Compliance | `gpo/Apply-DscBaseline.ps1` | Validates DC local state matches GPO definitions | +| AD Hygiene | `ad-objects/Get-StaleADObjects.ps1` | Stale accounts, orphans, unmanaged objects report | +| GPO Operations | `Restore-GPOBaseline.ps1`, `Get-UnmanagedGPOs.ps1` | Backup/restore, orphan detection | + +### GPO Capabilities + +The framework manages 15 setting types through 12 modular libraries: + +| Capability | Library | Description | +|---|---|---| +| Security policy | GPOPolicy.ps1 | GptTmpl.inf: password, lockout, Kerberos, user rights, security options | +| Registry settings | GPOPolicy.ps1 | Administrative Template values via `Set-GPRegistryValue` | +| Restricted groups | GPOPolicy.ps1 | Local group membership enforcement | +| GPO links | GPOPermissions.ps1 | OU linking with order and enforcement | +| Security filtering | GPOPermissions.ps1 | Deny Apply ACEs for group exemptions | +| Scripts | GPOScripts.ps1 | Startup/shutdown/logon/logoff script deployment | +| Advanced audit | GPOAudit.ps1 | 53 subcategory-level audit settings | +| Preferences | GPOPreferences.ps1 | 10 GPP types (tasks, drives, printers, shortcuts, etc.) | +| WMI filters | GPOWmiFilter.ps1 | OS-targeting filters | +| Backup/restore | GPOBackup.ps1 | Pre-apply snapshots with rollback | +| Firewall | GPOFirewall.ps1 | Rules and profile defaults | +| AppLocker | GPOAppLocker.ps1 | Application whitelisting (audit or enforce) | +| WDAC | GPOWdac.ps1 | Kernel-level code integrity policy | +| Folder redirection | GPOFolderRedirection.ps1 | User folder redirection to network paths | + +## Getting Started + +### Prerequisites + +- Windows Server 2016+ domain controller +- RSAT (Remote Server Administration Tools) on admin workstation +- Windows PowerShell 5.1 +- PowerShell modules: `GroupPolicy`, `ActiveDirectory`, `SecurityPolicyDsc` (for DSC validation) + +### Customization + +1. **Clone the repo** and update the definition files for your domain: + + - `ad-objects/ous.ps1` -- set `$domainDN` to your domain's distinguished name, rename OUs + - `ad-objects/groups.ps1` -- define your security groups and members + - `ad-objects/users.ps1` -- define your user accounts + - `ad-objects/delegations.ps1` -- define your OU delegation ACLs + - `ad-objects/password-policies.ps1` -- define fine-grained password policies + - `gpo/*/settings.ps1` -- update `LinkTo` paths with your OU DNs, update NETBIOS group references in `RestrictedGroups` + +2. **Test first**, then apply: + +```powershell +.\ad-objects\Apply-ADBaseline.ps1 -TestOnly # Drift detection, no changes +.\ad-objects\Apply-ADBaseline.ps1 # Apply AD objects + +.\gpo\Apply-GPOBaseline.ps1 -TestOnly # Drift detection, no changes +.\gpo\Apply-GPOBaseline.ps1 -GpUpdate # Apply GPO settings + gpupdate +``` + +### Adding a new GPO + +Create a directory under `gpo/` with a `settings.ps1` -- auto-discovered on next run. No code changes needed. + +## Example Security Model + +The included example definitions demonstrate a tiered admin model: + +| Tier | Account | Group | Access | +|---|---|---|---| +| Break-glass | `Administrator` | Domain Admins | Full domain control -- emergency only | +| Tier 0 (operations) | `t0admin` | MasterAdmins | Full control on managed OUs, GPO edit, DNS, RDP to DC | +| Tier 2 (helpdesk) | `jsmith` | DelegatedAdmins | Password resets, user properties in ExampleUsers only | + +- **Domain Admins** is reserved for break-glass scenarios only +- **MasterAdmins** has self-healing edit rights on all managed GPOs (no DA required) +- **DelegatedAdmins** are exempted from user desktop lockdown via GPO security filtering +- Fine-grained password policies (PSOs) enforce stricter requirements for admin tiers + +## Workflow + +### Day-to-day operations + +``` +1. Edit definitions on your admin workstation + - AD objects: ad-objects/ous.ps1, groups.ps1, users.ps1 + - GPO settings: gpo//settings.ps1 + +2. Commit and push + git add -A && git commit -m "description" && git push origin master + +3. RDP to DC + mstsc /v:dc01.example.internal + +4. Pull and test + cd C:\declarative-ad-framework + git pull origin master + .\ad-objects\Apply-ADBaseline.ps1 -TestOnly + .\gpo\Apply-GPOBaseline.ps1 -TestOnly + +5. Review drift output, then apply + .\ad-objects\Apply-ADBaseline.ps1 + .\gpo\Apply-GPOBaseline.ps1 -GpUpdate + +6. Confirm DC compliance + .\gpo\Apply-DscBaseline.ps1 -TestOnly +``` + +### Adding a new user + +1. Edit `ad-objects/users.ps1` -- add a hashtable with SamAccountName, Name, Path, MemberOf +2. Push to git, pull on DC +3. Run `.\ad-objects\Apply-ADBaseline.ps1` -- creates the user with a CSPRNG password +4. Read the password from `ad-objects/.credentials/.txt` +5. Securely share the password with the user, then delete the file +6. User must change password on first login + +### Adding a new GPO setting + +1. Edit `gpo//settings.ps1` -- add to SecurityPolicy, RegistrySettings, FirewallRules, AppLockerPolicy, etc. +2. Push to git, pull on DC +3. Run `.\gpo\Apply-GPOBaseline.ps1 -GpUpdate` + +### Creating a new GPO + +1. Create `gpo//settings.ps1` with GPOName, LinkTo, SecurityPolicy, RegistrySettings +2. Push to git, pull on DC +3. Run `.\gpo\Apply-GPOBaseline.ps1 -GpUpdate` -- auto-creates the GPO, applies settings, links to OU + +## Key Flags + +| Flag | Script | Effect | +|---|---|---| +| `-TestOnly` | Both | Drift detection, no changes (always run first) | +| `-GpUpdate` | GPO | Runs `gpupdate /force` after applying | +| `-NoBackup` | GPO | Skip automatic pre-apply backup | +| `-NoCleanup` | GPO | Keep stale registry values instead of removing them | + +## Example GPO Inventory + +| GPO | Linked To | Purpose | +|---|---|---| +| Default Domain Policy | Domain root | Password, lockout, Kerberos policies | +| Default Domain Controllers Policy | Domain Controllers OU | User rights assignments, security options | +| Admins-01 | ExampleAdmins OU | Session lock, PS logging, taskbar cleanup | +| Users-01 | ExampleUsers OU | Desktop lockdown (DelegatedAdmins exempted) | +| Workstations-01 | ExampleWorkstations OU | Audit, autorun, Windows Update, NLA, firewall, AppLocker (audit) | +| AdminWorkstations-01 | ExampleAdminWorkstations OU | PAW: full audit, PS transcription, firewall, AppLocker (audit), WDAC (audit) | +| Servers-01 | ExampleServers OU | Server hardening: full audit, PS transcription, firewall, GPP local groups | + +## DSC Validation + +`Apply-DscBaseline.ps1` is a second-layer check that validates the DC's **actual local state** against what the GPOs should have applied. It catches issues that the GPO baseline can't see -- processing failures, conflicting policies, or settings that were applied out-of-band. + +``` +Layer 1 (GPO Baseline): "Is the policy definition in SYSVOL correct?" +Layer 2 (DSC Baseline): "Did the DC actually apply it?" +``` + +Both DSC configurations read from their respective `settings.ps1` files -- single source of truth, no value duplication. + +**Important:** DSC apply mode writes directly to the local security database, bypassing GPO. It requires typing `APPLY` to confirm and should only be used for emergency remediation. + +## Bootstrap + +The first run of `Apply-GPOBaseline.ps1` must be executed as **Administrator** (Domain Admins) to grant MasterAdmins edit rights on the managed GPOs. After that, MasterAdmins is self-maintaining. + +```powershell +runas /user:EXAMPLE\Administrator "powershell.exe -ExecutionPolicy Bypass -Command cd C:\declarative-ad-framework\gpo; .\Apply-GPOBaseline.ps1 -GpUpdate" +``` + +## Recovery + +If the default GPOs become corrupted beyond repair: + +```powershell +dcgpofix /target:domain # Reset Default Domain Policy +dcgpofix /target:dc # Reset Default Domain Controllers Policy +``` + +Then re-run `Apply-GPOBaseline.ps1` to reapply all settings. + +## Repo Structure + +``` +declarative-ad-framework/ + README.md # This file + CHANGELOG.md # Version history + FRAMEWORK.md # Developer reference for extending the framework + ad-objects/ + Apply-ADBaseline.ps1 # Orchestration: OUs -> groups -> users -> membership -> delegations -> PSOs + Get-StaleADObjects.ps1 # Read-only: stale accounts, orphans, unmanaged objects + ous.ps1 # OU definitions + groups.ps1 # Security group definitions + users.ps1 # User account definitions (supports optional properties) + delegations.ps1 # OU delegation rules (ACLs) + password-policies.ps1 # Fine-grained password policy (PSO) definitions + lib/ + ADHelper.ps1 # Loader: dot-sources the 6 modules below + ADCore.ps1 # CSPRNG password generation + ADOrganizationalUnit.ps1 # OU ensure/compare + ADGroup.ps1 # Security group ensure/compare + ADUser.ps1 # User account ensure/compare + ADDelegation.ps1 # OU delegation ACLs + ADPasswordPolicy.ps1 # Fine-grained password policy ensure/compare + .credentials/ # Temp password files (gitignored, ACL-locked) + gpo/ + Apply-GPOBaseline.ps1 # Orchestration: GPO settings to AD + Apply-DscBaseline.ps1 # DC-local compliance (DSC, test-only) + Restore-GPOBaseline.ps1 # List/restore GPO backups + Get-UnmanagedGPOs.ps1 # Discover orphan GPOs not managed by framework + lib/ + GPOHelper.ps1 # Loader: dot-sources the 12 modules below + GPOCore.ps1 # SYSVOL paths, version bump, extension GUIDs, DSC helpers + GPOPolicy.ps1 # Security policy (GptTmpl.inf) + registry + restricted groups + GPOPermissions.ps1 # GPO links, management permissions, security filtering + GPOScripts.ps1 # Startup/shutdown/logon/logoff script deployment + GPOAudit.ps1 # Advanced audit policy (audit.csv) + GPOPreferences.ps1 # Group Policy Preferences XML (10 types) + GPOWmiFilter.ps1 # WMI filter creation + GPO linking + GPOBackup.ps1 # Pre-apply backup + restore functions + GPOFirewall.ps1 # Windows Firewall rules + profile management + GPOAppLocker.ps1 # AppLocker policy management + GPOWdac.ps1 # WDAC policy deployment + GPOFolderRedirection.ps1 # Folder redirection (fdeploy1.ini) + default-domain/ # Default Domain Policy + DSC config + default-domain-controller/ # Default DC Policy + DSC config + admins-01/ # Admin session/logging policy + users-01/ # User desktop lockdown + workstations-01/ # Workstation hardening + AppLocker audit + adminworkstations-01/ # PAW: forensics, AppLocker audit, WDAC audit + servers-01/ # Server hardening + GPP local groups + backups/ # Pre-apply GPO snapshots (gitignored) + Output/ # Compiled MOF files (gitignored) +``` + +## License + +This project is provided as-is for educational and operational use. Adapt the definition files to your environment. diff --git a/ad-objects/Apply-ADBaseline.ps1 b/ad-objects/Apply-ADBaseline.ps1 new file mode 100644 index 0000000..deed0a9 --- /dev/null +++ b/ad-objects/Apply-ADBaseline.ps1 @@ -0,0 +1,231 @@ +# Apply-ADBaseline.ps1 +# Orchestration script for managing AD objects (OUs, groups, users). +# +# Reads declarative definitions from ous.ps1, groups.ps1, users.ps1, +# delegations.ps1, and password-policies.ps1, then compares against +# live AD and applies changes. Works from any machine with RSAT. +# +# Usage: +# .\Apply-ADBaseline.ps1 # Apply all AD objects +# .\Apply-ADBaseline.ps1 -TestOnly # Compare desired vs. current (no changes) + +[CmdletBinding()] +param( + [switch]$TestOnly +) + +$ErrorActionPreference = 'Stop' +$ScriptRoot = $PSScriptRoot + +# ------------------------------------------------------------------- +# Load helper functions +# ------------------------------------------------------------------- +. (Join-Path $ScriptRoot 'lib\ADHelper.ps1') + +# ------------------------------------------------------------------- +# Load definitions +# ------------------------------------------------------------------- +$ous = & (Join-Path $ScriptRoot 'ous.ps1') +$groups = & (Join-Path $ScriptRoot 'groups.ps1') +$users = & (Join-Path $ScriptRoot 'users.ps1') +$delegations = & (Join-Path $ScriptRoot 'delegations.ps1') +$passwordPolicies = & (Join-Path $ScriptRoot 'password-policies.ps1') + +$totalDiffs = 0 +$createdUsers = @() + +# ------------------------------------------------------------------- +# Pre-flight: Warn about stale credential files (older than 24 hours) +# ------------------------------------------------------------------- +$credDir = Join-Path $ScriptRoot '.credentials' +if (Test-Path $credDir) { + $staleFiles = Get-ChildItem -Path $credDir -Filter '*.txt' | + Where-Object { $_.LastWriteTime -lt (Get-Date).AddHours(-24) } + if ($staleFiles.Count -gt 0) { + Write-Host '========================================================' -ForegroundColor Red + Write-Host ' WARNING: Stale credential files detected (>24 hours)' -ForegroundColor Red + Write-Host '========================================================' -ForegroundColor Red + foreach ($f in $staleFiles) { + $age = [math]::Round(((Get-Date) - $f.LastWriteTime).TotalHours, 1) + Write-Host " - $($f.Name) (${age}h old)" -ForegroundColor Red + } + Write-Host ' These should have been handed off and deleted.' -ForegroundColor Red + Write-Host '========================================================' -ForegroundColor Red + Write-Host '' + } +} + +# ------------------------------------------------------------------- +# Step 1: Organizational Units (must exist before groups/users) +# ------------------------------------------------------------------- +Write-Host "=== Organizational Units ===" -ForegroundColor White + +foreach ($ou in $ous) { + if ($TestOnly) { + $diff = Compare-ADOU -Name $ou.Name -Path $ou.Path + if ($diff) { $totalDiffs++ } + } else { + Ensure-ADOU -Name $ou.Name -Path $ou.Path -Description $ou.Description | Out-Null + } +} + +Write-Host '' + +# ------------------------------------------------------------------- +# Step 2: Security Groups (must exist before users can join them) +# ------------------------------------------------------------------- +Write-Host "=== Security Groups ===" -ForegroundColor White + +foreach ($group in $groups) { + if ($TestOnly) { + $diffs = Compare-ADSecurityGroup -Name $group.Name -Members $group.Members + $totalDiffs += @($diffs).Count + } else { + $params = @{ + Name = $group.Name + Path = $group.Path + Description = $group.Description + Scope = $group.Scope + } + # Don't sync members yet - users may not exist + Ensure-ADSecurityGroup @params | Out-Null + } +} + +Write-Host '' + +# ------------------------------------------------------------------- +# Step 3: User Accounts +# ------------------------------------------------------------------- +Write-Host "=== User Accounts ===" -ForegroundColor White + +foreach ($user in $users) { + # Extract optional properties (anything not in the core schema) + $coreKeys = @('SamAccountName','Name','GivenName','Surname','Path','Enabled','MemberOf') + $optionalProps = @{} + foreach ($key in $user.Keys) { + if ($key -notin $coreKeys) { + $optionalProps[$key] = $user[$key] + } + } + + if ($TestOnly) { + $diffs = Compare-ADUserAccount ` + -SamAccountName $user.SamAccountName ` + -Path $user.Path ` + -Enabled $user.Enabled ` + -MemberOf $user.MemberOf ` + -Properties $optionalProps + $totalDiffs += @($diffs).Count + } else { + $wasCreated = Ensure-ADUserAccount ` + -SamAccountName $user.SamAccountName ` + -Name $user.Name ` + -GivenName $user.GivenName ` + -Surname $user.Surname ` + -Path $user.Path ` + -Enabled $user.Enabled ` + -MemberOf $user.MemberOf ` + -Properties $optionalProps + if ($wasCreated) { + $createdUsers += $user.SamAccountName + } + } +} + +Write-Host '' + +# ------------------------------------------------------------------- +# Step 4: Sync group membership (now that users exist) +# ------------------------------------------------------------------- +if (-not $TestOnly) { + Write-Host "=== Group Membership Sync ===" -ForegroundColor White + + foreach ($group in $groups) { + if ($group.Members.Count -gt 0) { + Ensure-ADSecurityGroup -Name $group.Name -Path $group.Path -Scope $group.Scope -Members $group.Members | Out-Null + } + } + + Write-Host '' +} + +# ------------------------------------------------------------------- +# Step 5: OU Delegation (ACLs) +# ------------------------------------------------------------------- +Write-Host "=== OU Delegation ===" -ForegroundColor White + +foreach ($delegation in $delegations) { + foreach ($ou in $delegation.TargetOUs) { + if ($TestOnly) { + $diffs = Compare-OUDelegation ` + -GroupName $delegation.GroupName ` + -TargetOU $ou ` + -Rights $delegation.Rights + $totalDiffs += @($diffs).Count + } else { + Ensure-OUDelegation ` + -GroupName $delegation.GroupName ` + -TargetOU $ou ` + -Rights $delegation.Rights | Out-Null + } + } +} + +Write-Host '' + +# ------------------------------------------------------------------- +# Step 6: Fine-Grained Password Policies (PSOs) +# ------------------------------------------------------------------- +Write-Host "=== Password Policies ===" -ForegroundColor White + +foreach ($pso in $passwordPolicies) { + if ($TestOnly) { + $diffs = Compare-ADPasswordPolicy -Definition $pso + $totalDiffs += @($diffs).Count + } else { + Ensure-ADPasswordPolicy -Definition $pso + } +} + +Write-Host '' + +# ------------------------------------------------------------------- +# Summary +# ------------------------------------------------------------------- +if ($TestOnly) { + if ($totalDiffs -eq 0) { + Write-Host 'All AD objects match desired state.' -ForegroundColor Green + } else { + Write-Host "$totalDiffs difference(s) found." -ForegroundColor Red + } + Write-Host 'Compliance test complete. No changes were made.' -ForegroundColor Yellow +} else { + Write-Host 'All AD objects applied.' -ForegroundColor Green + + # --- Credential handoff warning --- + if ($createdUsers.Count -gt 0) { + $credDir = Join-Path $ScriptRoot '.credentials' + Write-Host '' + Write-Host '========================================================' -ForegroundColor Magenta + Write-Host ' ACTION REQUIRED: New user credentials pending handoff' -ForegroundColor Magenta + Write-Host '========================================================' -ForegroundColor Magenta + Write-Host '' + Write-Host " $($createdUsers.Count) user(s) were created with temporary passwords." -ForegroundColor Yellow + Write-Host ' Passwords are stored in:' -ForegroundColor Yellow + Write-Host " $credDir" -ForegroundColor White + Write-Host '' + foreach ($u in $createdUsers) { + Write-Host " - $u.txt" -ForegroundColor White + } + Write-Host '' + Write-Host ' You must:' -ForegroundColor Yellow + Write-Host ' 1. Read each credential file' -ForegroundColor White + Write-Host ' 2. Securely share the password with the end user' -ForegroundColor White + Write-Host ' 3. Delete the credential file after handoff' -ForegroundColor White + Write-Host '' + Write-Host ' These files are ACL-locked to your account only.' -ForegroundColor DarkGray + Write-Host ' Users must change their password on first login.' -ForegroundColor DarkGray + Write-Host '========================================================' -ForegroundColor Magenta + } +} diff --git a/ad-objects/Get-StaleADObjects.ps1 b/ad-objects/Get-StaleADObjects.ps1 new file mode 100644 index 0000000..25f69a1 --- /dev/null +++ b/ad-objects/Get-StaleADObjects.ps1 @@ -0,0 +1,202 @@ +# Get-StaleADObjects.ps1 +# Read-only reporting script -- discovers stale, orphaned, and unmanaged +# AD objects in managed OUs. +# +# Usage: +# .\Get-StaleADObjects.ps1 # Default 90-day threshold +# .\Get-StaleADObjects.ps1 -StaleDays 60 # Custom threshold + +[CmdletBinding()] +param( + [int]$StaleDays = 90 +) + +$ErrorActionPreference = 'Stop' +$ScriptRoot = $PSScriptRoot + +# ------------------------------------------------------------------- +# Load definitions (source of truth) +# ------------------------------------------------------------------- +$definedUsers = & (Join-Path $ScriptRoot 'users.ps1') +$definedGroups = & (Join-Path $ScriptRoot 'groups.ps1') +$definedOUs = & (Join-Path $ScriptRoot 'ous.ps1') + +$managedUserNames = @($definedUsers | ForEach-Object { $_.SamAccountName }) +$managedGroupNames = @($definedGroups | ForEach-Object { $_.Name }) +$managedOUDNs = @($definedOUs | ForEach-Object { "OU=$($_.Name),$($_.Path)" }) + +$staleDate = (Get-Date).AddDays(-$StaleDays) +$totalFindings = 0 + +Write-Host "=== Stale AD Object Report ===" -ForegroundColor Cyan +Write-Host " Threshold: $StaleDays days (before $(Get-Date $staleDate -Format 'yyyy-MM-dd'))" -ForegroundColor DarkGray +Write-Host '' + +# ------------------------------------------------------------------- +# 1. Stale user accounts (no login in N days) +# ------------------------------------------------------------------- +Write-Host "--- Stale User Accounts (no login in $StaleDays days) ---" -ForegroundColor White + +$staleCount = 0 +foreach ($ouDN in $managedOUDNs) { + $users = Get-ADUser -Filter * -SearchBase $ouDN -SearchScope OneLevel ` + -Properties LastLogonDate, Enabled -ErrorAction SilentlyContinue + + foreach ($user in $users) { + $isStale = $false + if ($null -eq $user.LastLogonDate) { + # Never logged in -- stale if account is old enough + $created = (Get-ADUser -Identity $user.SamAccountName -Properties WhenCreated).WhenCreated + if ($created -lt $staleDate) { + $isStale = $true + } + } elseif ($user.LastLogonDate -lt $staleDate) { + $isStale = $true + } + + if ($isStale -and $user.Enabled) { + $lastLogin = if ($null -eq $user.LastLogonDate) { 'Never' } else { Get-Date $user.LastLogonDate -Format 'yyyy-MM-dd' } + Write-Host " [STALE] $($user.SamAccountName) -- last login: $lastLogin" -ForegroundColor Yellow + $staleCount++ + } + } +} + +if ($staleCount -eq 0) { + Write-Host " No stale accounts found." -ForegroundColor Green +} +$totalFindings += $staleCount +Write-Host '' + +# ------------------------------------------------------------------- +# 2. Disabled accounts (not intentionally disabled in users.ps1) +# ------------------------------------------------------------------- +Write-Host "--- Unexpectedly Disabled Accounts ---" -ForegroundColor White + +$disabledCount = 0 +foreach ($ouDN in $managedOUDNs) { + $users = Get-ADUser -Filter { Enabled -eq $false } -SearchBase $ouDN -SearchScope OneLevel ` + -ErrorAction SilentlyContinue + + foreach ($user in $users) { + # Check if this user is intentionally disabled in definitions + $defined = $definedUsers | Where-Object { $_.SamAccountName -eq $user.SamAccountName } + if ($defined -and $defined.Enabled -eq $false) { + continue # Intentionally disabled + } + Write-Host " [DISABLED] $($user.SamAccountName) -- not marked as disabled in definitions" -ForegroundColor Yellow + $disabledCount++ + } +} + +if ($disabledCount -eq 0) { + Write-Host " No unexpectedly disabled accounts found." -ForegroundColor Green +} +$totalFindings += $disabledCount +Write-Host '' + +# ------------------------------------------------------------------- +# 3. Empty groups (zero members in managed OUs) +# ------------------------------------------------------------------- +Write-Host "--- Empty Groups ---" -ForegroundColor White + +$emptyCount = 0 +foreach ($ouDN in $managedOUDNs) { + $groups = Get-ADGroup -Filter * -SearchBase $ouDN -SearchScope OneLevel ` + -ErrorAction SilentlyContinue + + foreach ($group in $groups) { + $members = @(Get-ADGroupMember -Identity $group.SamAccountName -ErrorAction SilentlyContinue) + if ($members.Count -eq 0) { + Write-Host " [EMPTY] $($group.Name) -- zero members" -ForegroundColor Yellow + $emptyCount++ + } + } +} + +if ($emptyCount -eq 0) { + Write-Host " No empty groups found." -ForegroundColor Green +} +$totalFindings += $emptyCount +Write-Host '' + +# ------------------------------------------------------------------- +# 4. Unmanaged users (in managed OUs but not in users.ps1) +# ------------------------------------------------------------------- +Write-Host "--- Unmanaged Users (in managed OUs but not in definitions) ---" -ForegroundColor White + +$unmanagedUserCount = 0 +foreach ($ouDN in $managedOUDNs) { + $users = Get-ADUser -Filter * -SearchBase $ouDN -SearchScope OneLevel ` + -ErrorAction SilentlyContinue + + foreach ($user in $users) { + if ($user.SamAccountName -notin $managedUserNames) { + Write-Host " [UNMANAGED] $($user.SamAccountName) in $ouDN" -ForegroundColor Yellow + $unmanagedUserCount++ + } + } +} + +if ($unmanagedUserCount -eq 0) { + Write-Host " All users in managed OUs are defined." -ForegroundColor Green +} +$totalFindings += $unmanagedUserCount +Write-Host '' + +# ------------------------------------------------------------------- +# 5. Unmanaged groups (in managed OUs but not in groups.ps1) +# ------------------------------------------------------------------- +Write-Host "--- Unmanaged Groups (in managed OUs but not in definitions) ---" -ForegroundColor White + +$unmanagedGroupCount = 0 +foreach ($ouDN in $managedOUDNs) { + $groups = Get-ADGroup -Filter * -SearchBase $ouDN -SearchScope OneLevel ` + -ErrorAction SilentlyContinue + + foreach ($group in $groups) { + if ($group.Name -notin $managedGroupNames) { + Write-Host " [UNMANAGED] $($group.Name) in $ouDN" -ForegroundColor Yellow + $unmanagedGroupCount++ + } + } +} + +if ($unmanagedGroupCount -eq 0) { + Write-Host " All groups in managed OUs are defined." -ForegroundColor Green +} +$totalFindings += $unmanagedGroupCount +Write-Host '' + +# ------------------------------------------------------------------- +# 6. Pending credential files +# ------------------------------------------------------------------- +Write-Host "--- Pending Credential Files ---" -ForegroundColor White + +$credDir = Join-Path $ScriptRoot '.credentials' +$credCount = 0 +if (Test-Path $credDir) { + $credFiles = Get-ChildItem -Path $credDir -Filter '*.txt' -ErrorAction SilentlyContinue + foreach ($f in $credFiles) { + $age = [math]::Round(((Get-Date) - $f.LastWriteTime).TotalHours, 1) + $color = if ($age -gt 24) { 'Red' } else { 'Yellow' } + Write-Host " [PENDING] $($f.Name) -- ${age}h old" -ForegroundColor $color + $credCount++ + } +} + +if ($credCount -eq 0) { + Write-Host " No pending credential files." -ForegroundColor Green +} +$totalFindings += $credCount +Write-Host '' + +# ------------------------------------------------------------------- +# Summary +# ------------------------------------------------------------------- +if ($totalFindings -eq 0) { + Write-Host "No findings. All managed OUs are clean." -ForegroundColor Green +} else { + Write-Host "$totalFindings finding(s) across all checks." -ForegroundColor Red +} +Write-Host 'Report complete. No changes were made.' -ForegroundColor DarkGray diff --git a/ad-objects/delegations.ps1 b/ad-objects/delegations.ps1 new file mode 100644 index 0000000..5e91b00 --- /dev/null +++ b/ad-objects/delegations.ps1 @@ -0,0 +1,46 @@ +# OU Delegation Definitions +# Processed after groups and users exist (Step 5). +# Defines what security groups get what permissions on which OUs. +# +# Rights syntax: +# 'FullControl' - GenericAll on the OU and all descendants +# 'ListContents' - ListChildren on the OU itself +# 'ReadAllProperties' - ReadProperty (all) on the OU itself +# 'ResetPassword' - Extended right on descendant user objects +# 'ReadWriteProperty:' - Read+Write a specific attribute on descendant user objects + +$domainDN = 'DC=example,DC=internal' + +@( + @{ + GroupName = 'MasterAdmins' + TargetOUs = @( + "OU=ExampleUsers,$domainDN" + "OU=ExampleWorkstations,$domainDN" + "OU=ExampleServers,$domainDN" + "OU=ExampleAdmins,$domainDN" + "OU=ExampleAdminWorkstations,$domainDN" + ) + Rights = 'FullControl' + } + + @{ + GroupName = 'DelegatedAdmins' + TargetOUs = @( + "OU=ExampleUsers,$domainDN" + ) + Rights = @( + 'ListContents' + 'ReadAllProperties' + 'ResetPassword' + 'ReadWriteProperty:userAccountControl' + 'ReadWriteProperty:lockoutTime' + 'ReadWriteProperty:displayName' + 'ReadWriteProperty:givenName' + 'ReadWriteProperty:sn' + 'ReadWriteProperty:mail' + 'ReadWriteProperty:telephoneNumber' + 'ReadWriteProperty:description' + ) + } +) diff --git a/ad-objects/groups.ps1 b/ad-objects/groups.ps1 new file mode 100644 index 0000000..22e251f --- /dev/null +++ b/ad-objects/groups.ps1 @@ -0,0 +1,20 @@ +# Security Group Definitions +# Processed after OUs, before user membership sync. + +@( + @{ + Name = 'MasterAdmins' + Path = 'OU=ExampleAdmins,DC=example,DC=internal' + Scope = 'Global' + Description = 'Master administrators - full control over all managed OUs' + Members = @('t0admin') + } + + @{ + Name = 'DelegatedAdmins' + Path = 'OU=ExampleAdmins,DC=example,DC=internal' + Scope = 'Global' + Description = 'Delegated administrators - scoped helpdesk privileges in ExampleUsers' + Members = @('jsmith') + } +) diff --git a/ad-objects/lib/ADCore.ps1 b/ad-objects/lib/ADCore.ps1 new file mode 100644 index 0000000..cf3a5eb --- /dev/null +++ b/ad-objects/lib/ADCore.ps1 @@ -0,0 +1,55 @@ +# ADCore.ps1 +# Shared utility functions used by multiple AD modules. +# Must be loaded before other AD modules (ADUser.ps1 depends on New-RandomPassword). + +function Get-CryptoRandomInt { + <# + .SYNOPSIS + Returns a cryptographically secure random integer in [0, $Max). + #> + param([int]$Max) + + $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() + $bytes = [byte[]]::new(4) + $rng.GetBytes($bytes) + $rng.Dispose() + return [Math]::Abs([BitConverter]::ToInt32($bytes, 0)) % $Max +} + +function New-RandomPassword { + <# + .SYNOPSIS + Generates a cryptographically secure random password that meets AD complexity requirements. + #> + param( + [int]$Length = 16 + ) + + $upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ' + $lower = 'abcdefghjkmnpqrstuvwxyz' + $digits = '23456789' + $special = '!@#$%&*?' + + # Guarantee at least one of each type + $chars = [System.Collections.Generic.List[char]]::new() + $chars.Add($upper[(Get-CryptoRandomInt -Max $upper.Length)]) + $chars.Add($lower[(Get-CryptoRandomInt -Max $lower.Length)]) + $chars.Add($digits[(Get-CryptoRandomInt -Max $digits.Length)]) + $chars.Add($special[(Get-CryptoRandomInt -Max $special.Length)]) + + # Fill the rest randomly from all character sets + $all = $upper + $lower + $digits + $special + for ($i = $chars.Count; $i -lt $Length; $i++) { + $chars.Add($all[(Get-CryptoRandomInt -Max $all.Length)]) + } + + # Fisher-Yates shuffle using CSPRNG + for ($i = $chars.Count - 1; $i -gt 0; $i--) { + $j = Get-CryptoRandomInt -Max ($i + 1) + $temp = $chars[$i] + $chars[$i] = $chars[$j] + $chars[$j] = $temp + } + + return -join $chars +} diff --git a/ad-objects/lib/ADDelegation.ps1 b/ad-objects/lib/ADDelegation.ps1 new file mode 100644 index 0000000..a5f1ea8 --- /dev/null +++ b/ad-objects/lib/ADDelegation.ps1 @@ -0,0 +1,232 @@ +# ADDelegation.ps1 +# OU delegation (ACL) management. +# Uses AD schema GUIDs to construct ActiveDirectoryAccessRule objects. +# No dependencies on other AD modules. + +# AD Schema GUIDs -- well-known, universal across all AD forests. +# Verified against Microsoft AD Schema documentation. +# https://learn.microsoft.com/en-us/windows/win32/adschema/ + +$Script:ADClassGuid = @{ + user = [Guid]'bf967aba-0de6-11d0-a285-00aa003049e2' +} + +$Script:ADAttributeGuid = @{ + userAccountControl = [Guid]'bf967a68-0de6-11d0-a285-00aa003049e2' + lockoutTime = [Guid]'28630ebf-41d5-11d1-a9c1-0000f80367c1' + displayName = [Guid]'bf967953-0de6-11d0-a285-00aa003049e2' + givenName = [Guid]'f0f8ff8e-1191-11d0-a060-00aa006c33ed' + sn = [Guid]'bf967a41-0de6-11d0-a285-00aa003049e2' + mail = [Guid]'bf967961-0de6-11d0-a285-00aa003049e2' + telephoneNumber = [Guid]'bf967a49-0de6-11d0-a285-00aa003049e2' + description = [Guid]'bf967950-0de6-11d0-a285-00aa003049e2' +} + +$Script:ADExtendedRight = @{ + ResetPassword = [Guid]'00299570-246d-11d0-a768-00aa006e0529' +} + +function ConvertTo-DelegationACEs { + <# + .SYNOPSIS + Converts a rights specification into ActiveDirectoryAccessRule objects. + #> + param( + [Parameter(Mandatory)] + [System.Security.Principal.SecurityIdentifier]$SID, + + [Parameter(Mandatory)] + $Rights + ) + + $aces = @() + $rightsArray = @($Rights) + + foreach ($right in $rightsArray) { + if ($right -eq 'FullControl') { + $aces += New-Object System.DirectoryServices.ActiveDirectoryAccessRule( + $SID, + [System.DirectoryServices.ActiveDirectoryRights]::GenericAll, + [System.Security.AccessControl.AccessControlType]::Allow, + [System.DirectoryServices.ActiveDirectorySecurityInheritance]::All + ) + } + elseif ($right -eq 'ListContents') { + $aces += New-Object System.DirectoryServices.ActiveDirectoryAccessRule( + $SID, + [System.DirectoryServices.ActiveDirectoryRights]::ListChildren, + [System.Security.AccessControl.AccessControlType]::Allow, + [System.DirectoryServices.ActiveDirectorySecurityInheritance]::None + ) + } + elseif ($right -eq 'ReadAllProperties') { + $aces += New-Object System.DirectoryServices.ActiveDirectoryAccessRule( + $SID, + [System.DirectoryServices.ActiveDirectoryRights]::ReadProperty, + [System.Security.AccessControl.AccessControlType]::Allow, + [System.DirectoryServices.ActiveDirectorySecurityInheritance]::None + ) + } + elseif ($right -eq 'ResetPassword') { + $aces += New-Object System.DirectoryServices.ActiveDirectoryAccessRule( + $SID, + [System.DirectoryServices.ActiveDirectoryRights]::ExtendedRight, + [System.Security.AccessControl.AccessControlType]::Allow, + $Script:ADExtendedRight['ResetPassword'], + [System.DirectoryServices.ActiveDirectorySecurityInheritance]::Descendents, + $Script:ADClassGuid['user'] + ) + } + elseif ($right -match '^ReadWriteProperty:(.+)$') { + $attrName = $Matches[1] + + if (-not $Script:ADAttributeGuid.ContainsKey($attrName)) { + throw "Unknown AD attribute '$attrName' in delegation rule. Add it to `$Script:ADAttributeGuid in ADDelegation.ps1." + } + + $aces += New-Object System.DirectoryServices.ActiveDirectoryAccessRule( + $SID, + ([System.DirectoryServices.ActiveDirectoryRights]::ReadProperty -bor + [System.DirectoryServices.ActiveDirectoryRights]::WriteProperty), + [System.Security.AccessControl.AccessControlType]::Allow, + $Script:ADAttributeGuid[$attrName], + [System.DirectoryServices.ActiveDirectorySecurityInheritance]::Descendents, + $Script:ADClassGuid['user'] + ) + } + else { + throw "Unknown delegation right: '$right'. Valid: FullControl, ListContents, ReadAllProperties, ResetPassword, ReadWriteProperty:" + } + } + + return $aces +} + +function Test-ACEExists { + <# + .SYNOPSIS + Returns $true if an equivalent ACE already exists in the given ACL. + #> + param( + [System.DirectoryServices.ActiveDirectorySecurity]$ACL, + [System.DirectoryServices.ActiveDirectoryAccessRule]$DesiredACE + ) + + $desiredSID = $DesiredACE.IdentityReference.Value + + foreach ($existing in $ACL.Access) { + $existingSID = $null + try { + $existingSID = $existing.IdentityReference.Translate( + [System.Security.Principal.SecurityIdentifier]).Value + } catch { + continue + } + + if ($existingSID -ne $desiredSID) { continue } + # Bitwise subset check: AD merges compatible ACEs, so the stored + # rights may be a superset of what we asked for (e.g. ListChildren + # + ReadProperty merged into one ACE). + if (($existing.ActiveDirectoryRights -band $DesiredACE.ActiveDirectoryRights) -ne $DesiredACE.ActiveDirectoryRights) { continue } + if ($existing.AccessControlType -ne $DesiredACE.AccessControlType) { continue } + if ($existing.ObjectType -ne $DesiredACE.ObjectType) { continue } + if ($existing.InheritanceType -ne $DesiredACE.InheritanceType) { continue } + if ($existing.InheritedObjectType -ne $DesiredACE.InheritedObjectType) { continue } + + return $true + } + + return $false +} + +function Ensure-OUDelegation { + <# + .SYNOPSIS + Idempotently applies delegation ACEs to an OU for a given security group. + Returns the number of ACEs added (0 if already in desired state). + #> + param( + [Parameter(Mandatory)] + [string]$GroupName, + + [Parameter(Mandatory)] + [string]$TargetOU, + + [Parameter(Mandatory)] + $Rights + ) + + $group = Get-ADGroup -Identity $GroupName -ErrorAction Stop + $sid = New-Object System.Security.Principal.SecurityIdentifier($group.SID) + + $desiredACEs = ConvertTo-DelegationACEs -SID $sid -Rights $Rights + + $adPath = "AD:$TargetOU" + $acl = Get-Acl $adPath + + $added = 0 + foreach ($ace in $desiredACEs) { + if (-not (Test-ACEExists -ACL $acl -DesiredACE $ace)) { + $acl.AddAccessRule($ace) + $added++ + } + } + + if ($added -gt 0) { + Set-Acl $adPath $acl + Write-Host " [DELEGATED] $GroupName on $TargetOU ($added ACE(s) added)" -ForegroundColor Yellow + } else { + Write-Host " [OK] $GroupName delegation on $TargetOU" -ForegroundColor Green + } + + return $added +} + +function Compare-OUDelegation { + <# + .SYNOPSIS + Checks if delegation ACEs exist for a group on an OU. + Returns diff objects for any missing ACEs. + #> + param( + [Parameter(Mandatory)] + [string]$GroupName, + + [Parameter(Mandatory)] + [string]$TargetOU, + + [Parameter(Mandatory)] + $Rights + ) + + $diffs = @() + + $group = Get-ADGroup -Identity $GroupName -ErrorAction Stop + $sid = New-Object System.Security.Principal.SecurityIdentifier($group.SID) + + $desiredACEs = ConvertTo-DelegationACEs -SID $sid -Rights $Rights + + $adPath = "AD:$TargetOU" + $acl = Get-Acl $adPath + + $missing = 0 + foreach ($ace in $desiredACEs) { + if (-not (Test-ACEExists -ACL $acl -DesiredACE $ace)) { + $missing++ + } + } + + if ($missing -eq 0) { + Write-Host " [OK] $GroupName delegation on $TargetOU" -ForegroundColor Green + } else { + Write-Host " [DRIFT] $GroupName missing $missing ACE(s) on $TargetOU" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'Delegation' + Group = $GroupName + TargetOU = $TargetOU + Status = "Missing $missing ACE(s)" + } + } + + return $diffs +} diff --git a/ad-objects/lib/ADGroup.ps1 b/ad-objects/lib/ADGroup.ps1 new file mode 100644 index 0000000..15f0890 --- /dev/null +++ b/ad-objects/lib/ADGroup.ps1 @@ -0,0 +1,113 @@ +# ADGroup.ps1 +# Security group management. +# No dependencies on other AD modules. + +function Ensure-ADSecurityGroup { + <# + .SYNOPSIS + Idempotently creates a security group and syncs membership. + #> + param( + [Parameter(Mandatory)] + [string]$Name, + + [Parameter(Mandatory)] + [string]$Path, + + [string]$Description = '', + + [ValidateSet('Global', 'DomainLocal', 'Universal')] + [string]$Scope = 'Global', + + [string[]]$Members = @() + ) + + $created = $false + + try { + $group = Get-ADGroup -Identity $Name -ErrorAction Stop + Write-Host " [OK] Group exists: $Name" -ForegroundColor Green + } catch { + New-ADGroup -Name $Name -Path $Path -GroupScope $Scope -GroupCategory Security ` + -Description $Description -ProtectedFromAccidentalDeletion $true + Write-Host " [CREATED] Group: $Name" -ForegroundColor Yellow + $created = $true + } + + # Ensure protected from accidental deletion + $group = Get-ADGroup -Identity $Name -Properties ProtectedFromAccidentalDeletion + if (-not $group.ProtectedFromAccidentalDeletion) { + Set-ADObject -Identity $group.DistinguishedName -ProtectedFromAccidentalDeletion $true + Write-Host " [UPDATED] $Name ProtectedFromAccidentalDeletion=True" -ForegroundColor Yellow + } + + # Sync membership + if ($Members.Count -gt 0) { + $currentMembers = @(Get-ADGroupMember -Identity $Name -ErrorAction SilentlyContinue | Select-Object -ExpandProperty SamAccountName) + + foreach ($member in $Members) { + if ($member -notin $currentMembers) { + Add-ADGroupMember -Identity $Name -Members $member + Write-Host " [ADDED] $member -> $Name" -ForegroundColor Yellow + } + } + + # Remove members not in desired list + foreach ($current in $currentMembers) { + if ($current -notin $Members) { + Remove-ADGroupMember -Identity $Name -Members $current -Confirm:$false + Write-Host " [REMOVED] $current from $Name" -ForegroundColor Red + } + } + } + + return $created +} + +function Compare-ADSecurityGroup { + <# + .SYNOPSIS + Compares desired group state against AD. Returns diffs. + #> + param( + [Parameter(Mandatory)] + [string]$Name, + + [string[]]$Members = @() + ) + + $diffs = @() + + try { + $group = Get-ADGroup -Identity $Name -Properties ProtectedFromAccidentalDeletion -ErrorAction Stop + Write-Host " [OK] Group exists: $Name" -ForegroundColor Green + + # Check protection + if (-not $group.ProtectedFromAccidentalDeletion) { + Write-Host " [DRIFT] $Name not protected from accidental deletion" -ForegroundColor Red + $diffs += [PSCustomObject]@{ Type = 'GroupProtection'; Group = $Name; Status = 'Unprotected' } + } + + # Check membership + $currentMembers = @(Get-ADGroupMember -Identity $Name -ErrorAction SilentlyContinue | Select-Object -ExpandProperty SamAccountName) + + foreach ($member in $Members) { + if ($member -notin $currentMembers) { + Write-Host " [DRIFT] $member not in group $Name" -ForegroundColor Red + $diffs += [PSCustomObject]@{ Type = 'GroupMember'; Group = $Name; Member = $member; Status = 'Missing' } + } + } + + foreach ($current in $currentMembers) { + if ($current -notin $Members) { + Write-Host " [DRIFT] $current in group $Name but not in desired state" -ForegroundColor Red + $diffs += [PSCustomObject]@{ Type = 'GroupMember'; Group = $Name; Member = $current; Status = 'Extra' } + } + } + } catch { + Write-Host " [MISSING] Group: $Name" -ForegroundColor Red + $diffs += [PSCustomObject]@{ Type = 'Group'; Name = $Name; Status = 'Missing' } + } + + return $diffs +} diff --git a/ad-objects/lib/ADHelper.ps1 b/ad-objects/lib/ADHelper.ps1 new file mode 100644 index 0000000..f813c45 --- /dev/null +++ b/ad-objects/lib/ADHelper.ps1 @@ -0,0 +1,15 @@ +# ADHelper.ps1 +# Loader: dot-sources the modular AD helper library. +# Apply-ADBaseline.ps1 continues to load this single file -- zero breaking changes. + +$libDir = $PSScriptRoot + +# Core utilities (other modules depend on these) +. (Join-Path $libDir 'ADCore.ps1') + +# Feature modules (order-independent) +. (Join-Path $libDir 'ADOrganizationalUnit.ps1') +. (Join-Path $libDir 'ADGroup.ps1') +. (Join-Path $libDir 'ADUser.ps1') +. (Join-Path $libDir 'ADDelegation.ps1') +. (Join-Path $libDir 'ADPasswordPolicy.ps1') diff --git a/ad-objects/lib/ADOrganizationalUnit.ps1 b/ad-objects/lib/ADOrganizationalUnit.ps1 new file mode 100644 index 0000000..1f750f9 --- /dev/null +++ b/ad-objects/lib/ADOrganizationalUnit.ps1 @@ -0,0 +1,56 @@ +# ADOrganizationalUnit.ps1 +# Organizational Unit management. +# No dependencies on other AD modules. + +function Ensure-ADOU { + <# + .SYNOPSIS + Idempotently creates an OU. Returns $true if created, $false if already exists. + #> + param( + [Parameter(Mandatory)] + [string]$Name, + + [Parameter(Mandatory)] + [string]$Path, + + [string]$Description = '' + ) + + $dn = "OU=$Name,$Path" + + try { + Get-ADOrganizationalUnit -Identity $dn -ErrorAction Stop | Out-Null + Write-Host " [OK] OU exists: $Name" -ForegroundColor Green + return $false + } catch { + New-ADOrganizationalUnit -Name $Name -Path $Path -Description $Description -ProtectedFromAccidentalDeletion $true + Write-Host " [CREATED] OU: $Name ($dn)" -ForegroundColor Yellow + return $true + } +} + +function Compare-ADOU { + <# + .SYNOPSIS + Checks if an OU exists. Returns a diff object if missing. + #> + param( + [Parameter(Mandatory)] + [string]$Name, + + [Parameter(Mandatory)] + [string]$Path + ) + + $dn = "OU=$Name,$Path" + + try { + Get-ADOrganizationalUnit -Identity $dn -ErrorAction Stop | Out-Null + Write-Host " [OK] OU exists: $Name" -ForegroundColor Green + return $null + } catch { + Write-Host " [MISSING] OU: $Name ($dn)" -ForegroundColor Red + return [PSCustomObject]@{ Type = 'OU'; Name = $Name; Status = 'Missing' } + } +} diff --git a/ad-objects/lib/ADPasswordPolicy.ps1 b/ad-objects/lib/ADPasswordPolicy.ps1 new file mode 100644 index 0000000..2dc85bb --- /dev/null +++ b/ad-objects/lib/ADPasswordPolicy.ps1 @@ -0,0 +1,224 @@ +# ADPasswordPolicy.ps1 +# Fine-grained password policy (PSO) management. +# No dependencies on other AD modules. + +function Ensure-ADPasswordPolicy { + <# + .SYNOPSIS + Idempotently creates or updates a fine-grained password policy (PSO) + and syncs group linkage via AppliesTo. + #> + param( + [Parameter(Mandatory)] + [hashtable]$Definition + ) + + $name = $Definition.Name + + # Separate AppliesTo from PSO properties + $appliesTo = @() + if ($Definition.ContainsKey('AppliesTo')) { + $appliesTo = @($Definition.AppliesTo) + } + + try { + $pso = Get-ADFineGrainedPasswordPolicy -Identity $name -Properties Description -ErrorAction Stop + Write-Host " [OK] PSO exists: $name" -ForegroundColor Green + + # Check each property for drift + $propMap = @{ + 'Precedence' = 'Precedence' + 'MinPasswordLength' = 'MinPasswordLength' + 'PasswordHistoryCount' = 'PasswordHistoryCount' + 'MaxPasswordAge' = 'MaxPasswordAge' + 'MinPasswordAge' = 'MinPasswordAge' + 'ComplexityEnabled' = 'ComplexityEnabled' + 'ReversibleEncryptionEnabled' = 'ReversibleEncryptionEnabled' + 'LockoutThreshold' = 'LockoutThreshold' + 'LockoutDuration' = 'LockoutDuration' + 'LockoutObservationWindow' = 'LockoutObservationWindow' + } + + $updates = @{} + foreach ($key in $propMap.Keys) { + if ($Definition.ContainsKey($key)) { + $desired = $Definition[$key] + $actual = $pso.$($propMap[$key]) + + # Normalize TimeSpan comparisons + if ($desired -is [string] -and $actual -is [timespan]) { + $desiredTS = [timespan]::Parse($desired) + if ($actual -ne $desiredTS) { + $updates[$key] = $desired + } + } elseif ("$actual" -ne "$desired") { + $updates[$key] = $desired + } + } + } + + if ($Definition.ContainsKey('Description')) { + if ("$($pso.Description)" -ne "$($Definition.Description)") { + $updates['Description'] = $Definition.Description + } + } + + if ($updates.Count -gt 0) { + Set-ADFineGrainedPasswordPolicy -Identity $name @updates + foreach ($key in $updates.Keys) { + Write-Host " [UPDATED] $name $key='$($updates[$key])'" -ForegroundColor Yellow + } + } + + } catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException] { + # Build creation parameters + $createParams = @{ + Name = $name + Precedence = $Definition.Precedence + } + + # Map all supported properties + $createKeys = @( + 'Description', 'MinPasswordLength', 'PasswordHistoryCount', + 'MaxPasswordAge', 'MinPasswordAge', 'ComplexityEnabled', + 'ReversibleEncryptionEnabled', 'LockoutThreshold', + 'LockoutDuration', 'LockoutObservationWindow' + ) + foreach ($key in $createKeys) { + if ($Definition.ContainsKey($key)) { + $createParams[$key] = $Definition[$key] + } + } + + # ProtectedFromAccidentalDeletion for consistency + $createParams['ProtectedFromAccidentalDeletion'] = $true + + New-ADFineGrainedPasswordPolicy @createParams + Write-Host " [CREATED] PSO: $name (precedence $($Definition.Precedence))" -ForegroundColor Yellow + } + + # Sync AppliesTo group linkage + if ($appliesTo.Count -gt 0) { + $pso = Get-ADFineGrainedPasswordPolicy -Identity $name -Properties AppliesTo + $currentSubjects = @() + if ($pso.AppliesTo) { + $currentSubjects = @($pso.AppliesTo | ForEach-Object { + (Get-ADGroup -Identity $_ -ErrorAction SilentlyContinue).SamAccountName + }) + } + + foreach ($group in $appliesTo) { + if ($group -notin $currentSubjects) { + Add-ADFineGrainedPasswordPolicySubject -Identity $name -Subjects $group + Write-Host " [LINKED] $name -> $group" -ForegroundColor Yellow + } + } + + foreach ($current in $currentSubjects) { + if ($current -notin $appliesTo) { + Remove-ADFineGrainedPasswordPolicySubject -Identity $name -Subjects $current -Confirm:$false + Write-Host " [UNLINKED] $name -> $current" -ForegroundColor Red + } + } + } +} + +function Compare-ADPasswordPolicy { + <# + .SYNOPSIS + Compares desired PSO state against AD. Returns diffs. + #> + param( + [Parameter(Mandatory)] + [hashtable]$Definition + ) + + $name = $Definition.Name + $diffs = @() + + # Separate AppliesTo from PSO properties + $appliesTo = @() + if ($Definition.ContainsKey('AppliesTo')) { + $appliesTo = @($Definition.AppliesTo) + } + + try { + $pso = Get-ADFineGrainedPasswordPolicy -Identity $name -Properties AppliesTo, Description -ErrorAction Stop + Write-Host " [OK] PSO exists: $name" -ForegroundColor Green + + # Check each property + $propMap = @{ + 'Precedence' = 'Precedence' + 'MinPasswordLength' = 'MinPasswordLength' + 'PasswordHistoryCount' = 'PasswordHistoryCount' + 'MaxPasswordAge' = 'MaxPasswordAge' + 'MinPasswordAge' = 'MinPasswordAge' + 'ComplexityEnabled' = 'ComplexityEnabled' + 'ReversibleEncryptionEnabled' = 'ReversibleEncryptionEnabled' + 'LockoutThreshold' = 'LockoutThreshold' + 'LockoutDuration' = 'LockoutDuration' + 'LockoutObservationWindow' = 'LockoutObservationWindow' + } + + foreach ($key in $propMap.Keys) { + if ($Definition.ContainsKey($key)) { + $desired = $Definition[$key] + $actual = $pso.$($propMap[$key]) + + $isDrift = $false + if ($desired -is [string] -and $actual -is [timespan]) { + $desiredTS = [timespan]::Parse($desired) + if ($actual -ne $desiredTS) { $isDrift = $true } + } elseif ("$actual" -ne "$desired") { + $isDrift = $true + } + + if ($isDrift) { + Write-Host " [DRIFT] $name $key='$actual', expected '$desired'" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'PSOProperty'; PSO = $name + Property = $key; Current = $actual; Desired = $desired + } + } + } + } + + if ($Definition.ContainsKey('Description')) { + if ("$($pso.Description)" -ne "$($Definition.Description)") { + Write-Host " [DRIFT] $name Description='$($pso.Description)', expected '$($Definition.Description)'" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'PSOProperty'; PSO = $name + Property = 'Description'; Current = $pso.Description; Desired = $Definition.Description + } + } + } + + # Check AppliesTo linkage + $currentSubjects = @() + if ($pso.AppliesTo) { + $currentSubjects = @($pso.AppliesTo | ForEach-Object { + (Get-ADGroup -Identity $_ -ErrorAction SilentlyContinue).SamAccountName + }) + } + + foreach ($group in $appliesTo) { + if ($group -notin $currentSubjects) { + Write-Host " [DRIFT] $name not linked to $group" -ForegroundColor Red + $diffs += [PSCustomObject]@{ Type = 'PSOSubject'; PSO = $name; Group = $group; Status = 'Missing' } + } + } + + foreach ($current in $currentSubjects) { + if ($current -notin $appliesTo) { + Write-Host " [DRIFT] $name linked to $current but not in desired state" -ForegroundColor Red + $diffs += [PSCustomObject]@{ Type = 'PSOSubject'; PSO = $name; Group = $current; Status = 'Extra' } + } + } + + } catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException] { + Write-Host " [MISSING] PSO: $name" -ForegroundColor Red + $diffs += [PSCustomObject]@{ Type = 'PSO'; Name = $name; Status = 'Missing' } + } + + return $diffs +} diff --git a/ad-objects/lib/ADUser.ps1 b/ad-objects/lib/ADUser.ps1 new file mode 100644 index 0000000..636884c --- /dev/null +++ b/ad-objects/lib/ADUser.ps1 @@ -0,0 +1,200 @@ +# ADUser.ps1 +# User account management. +# Depends on: ADCore.ps1 (New-RandomPassword) + +function Ensure-ADUserAccount { + <# + .SYNOPSIS + Idempotently creates a user account, ensures correct OU placement and group membership. + #> + param( + [Parameter(Mandatory)] + [string]$SamAccountName, + + [Parameter(Mandatory)] + [string]$Name, + + [string]$GivenName = '', + [string]$Surname = '', + + [Parameter(Mandatory)] + [string]$Path, + + [bool]$Enabled = $true, + + [string[]]$MemberOf = @(), + + [hashtable]$Properties = @{} + ) + + $created = $false + + try { + $user = Get-ADUser -Identity $SamAccountName -Properties MemberOf -ErrorAction Stop + Write-Host " [OK] User exists: $SamAccountName" -ForegroundColor Green + + # Check OU placement + $currentOU = ($user.DistinguishedName -split ',', 2)[1] + if ($currentOU -ne $Path) { + Move-ADObject -Identity $user.DistinguishedName -TargetPath $Path + Write-Host " [MOVED] $SamAccountName -> $Path" -ForegroundColor Yellow + } + + # Check enabled state + if ($user.Enabled -ne $Enabled) { + Set-ADUser -Identity $SamAccountName -Enabled $Enabled + Write-Host " [UPDATED] $SamAccountName Enabled=$Enabled" -ForegroundColor Yellow + } + + # Check optional properties + if ($Properties.Count -gt 0) { + $propNames = @($Properties.Keys) + $current = Get-ADUser -Identity $SamAccountName -Properties $propNames + foreach ($prop in $propNames) { + $desired = $Properties[$prop] + $actual = $current.$prop + if ("$actual" -ne "$desired") { + Set-ADUser -Identity $SamAccountName -Replace @{ $prop = $desired } + Write-Host " [UPDATED] $SamAccountName $prop='$desired'" -ForegroundColor Yellow + } + } + } + + } catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException] { + $password = New-RandomPassword + $securePass = ConvertTo-SecureString $password -AsPlainText -Force + + $params = @{ + SamAccountName = $SamAccountName + Name = $Name + UserPrincipalName = "$SamAccountName@$((Get-ADDomain).DNSRoot)" + Path = $Path + AccountPassword = $securePass + Enabled = $Enabled + ChangePasswordAtLogon = $true + } + + if ($GivenName) { $params.GivenName = $GivenName } + if ($Surname) { $params.Surname = $Surname } + + # Merge optional properties into creation parameters + foreach ($key in $Properties.Keys) { + $params[$key] = $Properties[$key] + } + + New-ADUser @params + + # Write password to a secured file -- never to console (avoids transcript leaks) + $credDir = Join-Path (Split-Path $PSScriptRoot -Parent) '.credentials' + $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name + + if (-not (Test-Path $credDir)) { + New-Item -ItemType Directory -Path $credDir -Force | Out-Null + + # Lock directory permissions to current user only + $dirAcl = Get-Acl $credDir + $dirAcl.SetAccessRuleProtection($true, $false) + $dirRule = New-Object System.Security.AccessControl.FileSystemAccessRule( + $currentUser, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow') + $dirAcl.AddAccessRule($dirRule) + Set-Acl -Path $credDir -AclObject $dirAcl + } + + $credFile = Join-Path $credDir "$SamAccountName.txt" + Set-Content -Path $credFile -Value "User: $SamAccountName`nPassword: $password`nCreated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')`nNote: Must change on first login`nDelete this file after handing off the password." + + # Lock file permissions to current user only + $acl = Get-Acl $credFile + $acl.SetAccessRuleProtection($true, $false) + $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( + $currentUser, 'FullControl', 'Allow') + $acl.AddAccessRule($rule) + Set-Acl -Path $credFile -AclObject $acl + + Write-Host " [CREATED] User: $SamAccountName (password saved to $credFile)" -ForegroundColor Yellow + $created = $true + } + + # Sync group membership + foreach ($group in $MemberOf) { + $currentGroups = @(Get-ADPrincipalGroupMembership -Identity $SamAccountName | Select-Object -ExpandProperty Name) + if ($group -notin $currentGroups) { + Add-ADGroupMember -Identity $group -Members $SamAccountName + Write-Host " [ADDED] $SamAccountName -> $group" -ForegroundColor Yellow + } + } + + return $created +} + +function Compare-ADUserAccount { + <# + .SYNOPSIS + Compares desired user state against AD. Returns diffs. + #> + param( + [Parameter(Mandatory)] + [string]$SamAccountName, + + [Parameter(Mandatory)] + [string]$Path, + + [bool]$Enabled = $true, + + [string[]]$MemberOf = @(), + + [hashtable]$Properties = @{} + ) + + $diffs = @() + + try { + $user = Get-ADUser -Identity $SamAccountName -Properties MemberOf -ErrorAction Stop + Write-Host " [OK] User exists: $SamAccountName" -ForegroundColor Green + + # Check OU placement + $currentOU = ($user.DistinguishedName -split ',', 2)[1] + if ($currentOU -ne $Path) { + Write-Host " [DRIFT] $SamAccountName in $currentOU, expected $Path" -ForegroundColor Red + $diffs += [PSCustomObject]@{ Type = 'UserOU'; User = $SamAccountName; Current = $currentOU; Desired = $Path } + } + + # Check enabled state + if ($user.Enabled -ne $Enabled) { + Write-Host " [DRIFT] $SamAccountName Enabled=$($user.Enabled), expected $Enabled" -ForegroundColor Red + $diffs += [PSCustomObject]@{ Type = 'UserEnabled'; User = $SamAccountName; Current = $user.Enabled; Desired = $Enabled } + } + + # Check optional properties + if ($Properties.Count -gt 0) { + $propNames = @($Properties.Keys) + $current = Get-ADUser -Identity $SamAccountName -Properties $propNames + foreach ($prop in $propNames) { + $desired = $Properties[$prop] + $actual = $current.$prop + if ("$actual" -ne "$desired") { + Write-Host " [DRIFT] $SamAccountName $prop='$actual', expected '$desired'" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'UserProperty'; User = $SamAccountName + Property = $prop; Current = $actual; Desired = $desired + } + } + } + } + + # Check group membership + $currentGroups = @(Get-ADPrincipalGroupMembership -Identity $SamAccountName | Select-Object -ExpandProperty Name) + foreach ($group in $MemberOf) { + if ($group -notin $currentGroups) { + Write-Host " [DRIFT] $SamAccountName not in group $group" -ForegroundColor Red + $diffs += [PSCustomObject]@{ Type = 'UserGroup'; User = $SamAccountName; Group = $group; Status = 'Missing' } + } + } + + } catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException] { + Write-Host " [MISSING] User: $SamAccountName" -ForegroundColor Red + $diffs += [PSCustomObject]@{ Type = 'User'; Name = $SamAccountName; Status = 'Missing' } + } + + return $diffs +} diff --git a/ad-objects/ous.ps1 b/ad-objects/ous.ps1 new file mode 100644 index 0000000..6b90a1b --- /dev/null +++ b/ad-objects/ous.ps1 @@ -0,0 +1,32 @@ +# Organizational Unit Definitions +# Processed first — OUs must exist before groups and users can be placed in them. + +$domainDN = 'DC=example,DC=internal' + +@( + @{ + Name = 'ExampleUsers' + Path = $domainDN + Description = 'Standard user accounts' + } + @{ + Name = 'ExampleWorkstations' + Path = $domainDN + Description = 'Domain-joined workstations' + } + @{ + Name = 'ExampleServers' + Path = $domainDN + Description = 'Domain-joined servers' + } + @{ + Name = 'ExampleAdmins' + Path = $domainDN + Description = 'Delegated administrator accounts' + } + @{ + Name = 'ExampleAdminWorkstations' + Path = $domainDN + Description = 'Privileged access workstations for admin accounts' + } +) diff --git a/ad-objects/password-policies.ps1 b/ad-objects/password-policies.ps1 new file mode 100644 index 0000000..c52c89e --- /dev/null +++ b/ad-objects/password-policies.ps1 @@ -0,0 +1,37 @@ +# Fine-Grained Password Policy Definitions (PSOs) +# Override Default Domain Policy for specific groups. +# Lower Precedence number = higher priority. + +@( + @{ + Name = 'PSO-MasterAdmins' + Description = 'Strict password policy for Tier 0 admin accounts' + Precedence = 10 + MinPasswordLength = 16 + PasswordHistoryCount = 48 + MaxPasswordAge = '30.00:00:00' + MinPasswordAge = '1.00:00:00' + ComplexityEnabled = $true + ReversibleEncryptionEnabled = $false + LockoutThreshold = 3 + LockoutDuration = '00:30:00' + LockoutObservationWindow = '00:30:00' + AppliesTo = @('MasterAdmins') + } + + @{ + Name = 'PSO-DelegatedAdmins' + Description = 'Moderate password policy for helpdesk admins' + Precedence = 20 + MinPasswordLength = 12 + PasswordHistoryCount = 24 + MaxPasswordAge = '42.00:00:00' + MinPasswordAge = '1.00:00:00' + ComplexityEnabled = $true + ReversibleEncryptionEnabled = $false + LockoutThreshold = 5 + LockoutDuration = '00:30:00' + LockoutObservationWindow = '00:30:00' + AppliesTo = @('DelegatedAdmins') + } +) diff --git a/ad-objects/users.ps1 b/ad-objects/users.ps1 new file mode 100644 index 0000000..87db4d0 --- /dev/null +++ b/ad-objects/users.ps1 @@ -0,0 +1,47 @@ +# User Account Definitions +# Processed after OUs and groups. +# New users get a CSPRNG password saved to ad-objects/.credentials/ (never printed to console). + +@( + # --- Master Admins (ExampleAdmins OU) --- + + @{ + SamAccountName = 't0admin' + Name = 'Tier Zero Admin' + GivenName = 'Tier' + Surname = 'Admin' + Path = 'OU=ExampleAdmins,DC=example,DC=internal' + Enabled = $true + MemberOf = @('MasterAdmins', 'Administrators', 'Group Policy Creator Owners', 'DnsAdmins', 'Remote Desktop Users') + Description = 'Tier 0 master admin -- GPO/AD management via RDP to DC01' + Title = 'Master Administrator' + Department = 'IT' + } + + # --- Delegated Admins (ExampleUsers OU) --- + + @{ + SamAccountName = 'jsmith' + Name = 'John Smith' + GivenName = 'John' + Surname = 'Smith' + Path = 'OU=ExampleUsers,DC=example,DC=internal' + Enabled = $true + MemberOf = @('DelegatedAdmins') + Description = 'Delegated admin -- helpdesk via RSAT on WS01' + Title = 'Systems Administrator' + Department = 'IT' + } + + # --- Standard Users (ExampleUsers OU) --- + + @{ + SamAccountName = 'jdoe' + Name = 'Jane Doe' + GivenName = 'Jane' + Surname = 'Doe' + Path = 'OU=ExampleUsers,DC=example,DC=internal' + Enabled = $true + MemberOf = @() + } +) diff --git a/gpo/Apply-DSCBaseline.ps1 b/gpo/Apply-DSCBaseline.ps1 new file mode 100644 index 0000000..56bb221 --- /dev/null +++ b/gpo/Apply-DSCBaseline.ps1 @@ -0,0 +1,92 @@ +# Apply-DscBaseline.ps1 +# DC-local compliance validation using DSC. +# +# Compiles DSC configurations from settings.ps1 (same source of truth as +# Apply-GPOBaseline.ps1), then tests local state against the compiled MOF. +# This script is test-only by design — it never modifies the system. +# To fix drift, edit settings.ps1 and run Apply-GPOBaseline.ps1. +# +# Usage: +# .\Apply-DscBaseline.ps1 # Check DC compliance (read-only) + +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$ScriptRoot = $PSScriptRoot +$OutputPath = Join-Path $ScriptRoot 'Output' + +# ------------------------------------------------------------------- +# Load shared helper functions (needed by DSC config files) +# ------------------------------------------------------------------- +. (Join-Path $ScriptRoot 'lib\GPOHelper.ps1') + +# ------------------------------------------------------------------- +# Step 1: Ensure SecurityPolicyDsc module is installed +# ------------------------------------------------------------------- +Write-Host 'Checking for SecurityPolicyDsc module...' -ForegroundColor Cyan +if (-not (Get-Module -ListAvailable -Name SecurityPolicyDsc)) { + Write-Host 'Installing SecurityPolicyDsc from PSGallery...' -ForegroundColor Yellow + Install-Module -Name SecurityPolicyDsc -Repository PSGallery -Force -Scope AllUsers + Write-Host 'Installed.' -ForegroundColor Green +} else { + Write-Host 'SecurityPolicyDsc is already installed.' -ForegroundColor Green +} + +# ------------------------------------------------------------------- +# Step 2: Create output directory for compiled MOF files +# ------------------------------------------------------------------- +if (-not (Test-Path $OutputPath)) { + New-Item -ItemType Directory -Path $OutputPath | Out-Null +} + +# ------------------------------------------------------------------- +# Step 3: Load and compile configurations +# ------------------------------------------------------------------- +$configurations = @( + @{ + Name = 'DefaultDomainPolicy' + Script = Join-Path $ScriptRoot 'default-domain\DefaultDomainPolicy.ps1' + }, + @{ + Name = 'DefaultDCPolicy' + Script = Join-Path $ScriptRoot 'default-domain-controller\DefaultDCPolicy.ps1' + } +) + +foreach ($config in $configurations) { + Write-Host "`nLoading $($config.Name)..." -ForegroundColor Cyan + . $config.Script + + $mofOutput = Join-Path $OutputPath $config.Name + Write-Host "Compiling $($config.Name) -> $mofOutput" -ForegroundColor Cyan + & $config.Name -OutputPath $mofOutput | Out-Null + Write-Host "Compiled." -ForegroundColor Green +} + +# ------------------------------------------------------------------- +# Step 4: Test compliance +# ------------------------------------------------------------------- +foreach ($config in $configurations) { + $mofPath = Join-Path $OutputPath $config.Name + + Write-Host "`n--- Testing compliance: $($config.Name) ---" -ForegroundColor Yellow + $result = Test-DscConfiguration -ReferenceConfiguration (Join-Path $mofPath 'localhost.mof') + if ($result.InDesiredState) { + Write-Host " [OK] All $($result.ResourcesInDesiredState.Count) resource(s) in desired state" -ForegroundColor Green + } else { + Write-Host " [DRIFT] $($result.ResourcesNotInDesiredState.Count) resource(s) out of desired state:" -ForegroundColor Red + foreach ($resource in $result.ResourcesNotInDesiredState) { + Write-Host " $($resource.ResourceId)" -ForegroundColor Red + } + if ($result.ResourcesInDesiredState.Count -gt 0) { + Write-Host " [OK] $($result.ResourcesInDesiredState.Count) resource(s) in desired state" -ForegroundColor Green + } + } +} + +# ------------------------------------------------------------------- +# Step 5: Summary +# ------------------------------------------------------------------- +Write-Host "`nCompliance test complete. No changes were made." -ForegroundColor Yellow +Write-Host 'To fix drift, edit settings.ps1 and run Apply-GPOBaseline.ps1.' -ForegroundColor DarkGray diff --git a/gpo/Apply-GPOBaseline.ps1 b/gpo/Apply-GPOBaseline.ps1 new file mode 100644 index 0000000..88bcde7 --- /dev/null +++ b/gpo/Apply-GPOBaseline.ps1 @@ -0,0 +1,376 @@ +# Apply-GPOBaseline.ps1 +# Orchestration script for applying GPO settings to Active Directory. +# +# Reads settings.ps1 from each GPO directory, compares against the live +# GPO in AD, and applies changes. Works from any machine with RSAT. +# +# Usage: +# .\Apply-GPOBaseline.ps1 # Apply all GPO settings +# .\Apply-GPOBaseline.ps1 -TestOnly # Compare desired vs. current (no changes) + +[CmdletBinding()] +param( + [switch]$TestOnly, + [switch]$GpUpdate, + [switch]$NoBackup, + [switch]$NoCleanup +) + +$ErrorActionPreference = 'Stop' +$ScriptRoot = $PSScriptRoot + +# ------------------------------------------------------------------- +# Load helper functions +# ------------------------------------------------------------------- +. (Join-Path $ScriptRoot 'lib\GPOHelper.ps1') + +# ------------------------------------------------------------------- +# Groups that should have edit rights on ALL managed GPOs. +# This ensures Tier 0 operational admins can manage GPOs without +# Domain Admins membership (DA = break-glass only). +# ------------------------------------------------------------------- +$ManagementGroups = @('MasterAdmins') + +# ------------------------------------------------------------------- +# Discover GPO directories (each must contain a settings.ps1) +# ------------------------------------------------------------------- +$gpoDirs = Get-ChildItem -Path $ScriptRoot -Directory | + Where-Object { Test-Path (Join-Path $_.FullName 'settings.ps1') } + +if ($gpoDirs.Count -eq 0) { + Write-Host 'No GPO directories with settings.ps1 found.' -ForegroundColor Red + exit 1 +} + +Write-Host "Found $($gpoDirs.Count) GPO configuration(s):`n" -ForegroundColor Cyan + +$totalDiffs = 0 + +# ------------------------------------------------------------------- +# Process each GPO +# ------------------------------------------------------------------- +foreach ($dir in $gpoDirs) { + $settingsPath = Join-Path $dir.FullName 'settings.ps1' + $settings = & $settingsPath + + $gpoName = $settings.GPOName + Write-Host "=== $gpoName ===" -ForegroundColor White + + # Verify GPO exists in AD — create if missing + try { + $gpo = Get-GPO -Name $gpoName -ErrorAction Stop + Write-Host " GPO found: {$($gpo.Id)}" -ForegroundColor Green + } catch { + if ($TestOnly) { + Write-Host " [DRIFT] GPO '$gpoName' does not exist yet (will be created on apply)" -ForegroundColor Yellow + $totalDiffs++ + Write-Host '' + continue + } else { + Write-Host " GPO '$gpoName' not found - creating..." -ForegroundColor Yellow + $newGpoParams = @{ Name = $gpoName } + if ($settings.Description) { $newGpoParams.Comment = $settings.Description } + $gpo = New-GPO @newGpoParams + Write-Host " [CREATED] GPO: $gpoName {$($gpo.Id)}" -ForegroundColor Green + } + } + + # --- GPO Description --- + if ($settings.Description) { + if ($TestOnly) { + if ($gpo.Description -ne $settings.Description) { + Write-Host " [DRIFT] Description: '$($gpo.Description)' -> '$($settings.Description)'" -ForegroundColor Red + $totalDiffs++ + } + } else { + if ($gpo.Description -ne $settings.Description) { + $gpo.Description = $settings.Description + Write-Host " [UPDATED] Description set" -ForegroundColor Yellow + } + } + } + + # --- GPO Status (enabled/disabled sections) --- + $hasStatusConfig = $settings.ContainsKey('DisableUserConfiguration') -or + $settings.ContainsKey('DisableComputerConfiguration') + if ($hasStatusConfig) { + $disableUser = if ($settings.ContainsKey('DisableUserConfiguration')) { + $settings.DisableUserConfiguration + } else { $false } + $disableComputer = if ($settings.ContainsKey('DisableComputerConfiguration')) { + $settings.DisableComputerConfiguration + } else { $false } + + if ($TestOnly) { + $statusDiff = Compare-GPOStatus -GPOName $gpoName ` + -DisableUserConfiguration $disableUser ` + -DisableComputerConfiguration $disableComputer + if ($statusDiff) { $totalDiffs++ } + } else { + Ensure-GPOStatus -GPOName $gpoName ` + -DisableUserConfiguration $disableUser ` + -DisableComputerConfiguration $disableComputer + } + } + + # --- Pre-Apply Backup --- + if (-not $TestOnly -and -not $NoBackup) { + try { + Backup-GPOState -GPOName $gpoName | Out-Null + } catch { + Write-Host " [WARN] Backup failed: $($_.Exception.Message)" -ForegroundColor Yellow + Write-Host " Continuing with apply..." -ForegroundColor Yellow + } + } + + # --- Management Permissions --- + foreach ($mgmtGroup in $ManagementGroups) { + if ($TestOnly) { + $permDiff = Compare-GPOManagementPermission -GPOName $gpoName -GroupName $mgmtGroup + if ($permDiff) { $totalDiffs++ } + } else { + Ensure-GPOManagementPermission -GPOName $gpoName -GroupName $mgmtGroup + } + } + + # --- Version scope tracking (for deferred bump at end of GPO) --- + $bumpMachine = $false + $bumpUser = $false + + # --- Restricted Groups -> merge into SecurityPolicy --- + if ($settings.RestrictedGroups -and $settings.RestrictedGroups.Count -gt 0) { + if ($settings.SecurityPolicy -and $settings.SecurityPolicy.ContainsKey('Group Membership')) { + throw "GPO '$gpoName': Use RestrictedGroups OR SecurityPolicy['Group Membership'], not both." + } + $gmEntries = ConvertTo-RestrictedGroupEntries -RestrictedGroups $settings.RestrictedGroups + if (-not $settings.SecurityPolicy) { $settings.SecurityPolicy = @{} } + $settings.SecurityPolicy['Group Membership'] = $gmEntries + } + + # --- Security Policy (GptTmpl.inf) --- + if ($settings.SecurityPolicy -and $settings.SecurityPolicy.Count -gt 0) { + if ($TestOnly) { + Write-Host " Comparing security policy..." -ForegroundColor Yellow + $diffs = Compare-GPOSecurityPolicy -GPOName $gpoName -SecurityPolicy $settings.SecurityPolicy + + if ($diffs.Count -eq 0) { + Write-Host " [OK] Security policy matches desired state" -ForegroundColor Green + } else { + Write-Host " [DRIFT] $($diffs.Count) difference(s) found:" -ForegroundColor Red + foreach ($diff in $diffs) { + Write-Host " [$($diff.Section)] $($diff.Setting): '$($diff.Current)' -> '$($diff.Desired)'" -ForegroundColor Red + } + $totalDiffs += $diffs.Count + } + } else { + Write-Host " Applying security policy..." -ForegroundColor Yellow + Set-GPOSecurityPolicy -GPOName $gpoName -SecurityPolicy $settings.SecurityPolicy + $bumpMachine = $true + } + } else { + Write-Host " No security policy settings defined." -ForegroundColor DarkGray + } + + # --- Registry Settings (Administrative Templates) --- + # Note: Set-GPRegistryValue handles version bumping internally — no manual bump needed + if ($settings.RegistrySettings -and $settings.RegistrySettings.Count -gt 0) { + if ($TestOnly) { + Write-Host " Comparing registry settings..." -ForegroundColor Yellow + $regDiffs = Compare-GPORegistrySettings -GPOName $gpoName -RegistrySettings $settings.RegistrySettings + + if ($regDiffs.Count -eq 0) { + Write-Host " [OK] Registry settings match desired state" -ForegroundColor Green + } else { + Write-Host " [DRIFT] $($regDiffs.Count) registry difference(s) found:" -ForegroundColor Red + foreach ($diff in $regDiffs) { + Write-Host " $($diff.Key)\$($diff.ValueName): '$($diff.Current)' -> '$($diff.Desired)'" -ForegroundColor Red + } + $totalDiffs += $regDiffs.Count + } + } else { + Write-Host " Applying registry settings..." -ForegroundColor Yellow + Set-GPORegistrySettings -GPOName $gpoName -RegistrySettings $settings.RegistrySettings ` + -Cleanup:(-not $NoCleanup) + } + } + + # --- GPO Link(s) --- + if ($settings.LinkTo) { + $links = Normalize-GPOLinkTo -LinkTo $settings.LinkTo + foreach ($link in $links) { + if ($TestOnly) { + $linkDiffs = Compare-GPOLink -GPOName $gpoName -TargetOU $link.Target ` + -Order $link.Order -Enforced $link.Enforced + $totalDiffs += @($linkDiffs).Count + } else { + Ensure-GPOLink -GPOName $gpoName -TargetOU $link.Target ` + -Order $link.Order -Enforced $link.Enforced + } + } + } + + # --- Security Filtering (Deny Apply) --- + if ($settings.SecurityFiltering -and $settings.SecurityFiltering.DenyApply) { + if ($TestOnly) { + Write-Host " Checking security filtering..." -ForegroundColor Yellow + $filterDiffs = Compare-GPOSecurityFiltering -GPOName $gpoName -DenyApply $settings.SecurityFiltering.DenyApply + $totalDiffs += @($filterDiffs).Count + } else { + Ensure-GPOSecurityFiltering -GPOName $gpoName -DenyApply $settings.SecurityFiltering.DenyApply + } + } + + # --- WMI Filter --- + if ($settings.WMIFilter) { + $wmiDesc = if ($settings.WMIFilter.Description) { $settings.WMIFilter.Description } else { '' } + if ($TestOnly) { + $wmiDiffs = Compare-GPOWmiFilter -GPOName $gpoName -FilterName $settings.WMIFilter.Name ` + -Description $wmiDesc -Query $settings.WMIFilter.Query + $totalDiffs += @($wmiDiffs).Count + } else { + Ensure-GPOWmiFilter -GPOName $gpoName -FilterName $settings.WMIFilter.Name ` + -Description $wmiDesc -Query $settings.WMIFilter.Query + } + } + + # --- Scripts (Startup / Shutdown / Logon / Logoff) --- + if ($settings.Scripts) { + if ($TestOnly) { + $scriptDiffs = Compare-GPOScripts -GPOName $gpoName -Scripts $settings.Scripts -SourceDir $dir.FullName + $totalDiffs += @($scriptDiffs).Count + } else { + Set-GPOScripts -GPOName $gpoName -Scripts $settings.Scripts -SourceDir $dir.FullName + foreach ($type in $settings.Scripts.Keys) { + if ($type -like 'Machine*') { $bumpMachine = $true } + if ($type -like 'User*') { $bumpUser = $true } + } + } + } + + # --- Advanced Audit Policy (audit.csv) --- + if ($settings.AdvancedAuditPolicy -and $settings.AdvancedAuditPolicy.Count -gt 0) { + if ($TestOnly) { + $auditDiffs = Compare-GPOAdvancedAuditPolicy -GPOName $gpoName -AuditPolicy $settings.AdvancedAuditPolicy + $totalDiffs += @($auditDiffs).Count + } else { + Set-GPOAdvancedAuditPolicy -GPOName $gpoName -AuditPolicy $settings.AdvancedAuditPolicy + $bumpMachine = $true + } + } + + # --- Group Policy Preferences --- + if ($settings.Preferences) { + if ($TestOnly) { + $prefDiffs = Compare-GPOPreferences -GPOName $gpoName -Preferences $settings.Preferences + $totalDiffs += @($prefDiffs).Count + } else { + Set-GPOPreferences -GPOName $gpoName -Preferences $settings.Preferences + foreach ($typeName in $settings.Preferences.Keys) { + switch ($typeName) { + 'DriveMaps' { $bumpUser = $true } + 'Printers' { $bumpUser = $true } + 'Services' { $bumpMachine = $true } + 'NetworkShares' { $bumpMachine = $true } + 'LocalUsersAndGroups' { $bumpMachine = $true } + default { + foreach ($item in $settings.Preferences[$typeName]) { + if ($item.Scope -eq 'User') { $bumpUser = $true } else { $bumpMachine = $true } + } + } + } + } + } + } + + # --- Firewall Profiles --- + if ($settings.FirewallProfiles) { + if ($TestOnly) { + $fwProfileDiffs = Compare-GPOFirewallProfiles -GPOName $gpoName -FirewallProfiles $settings.FirewallProfiles + $totalDiffs += @($fwProfileDiffs).Count + } else { + Set-GPOFirewallProfiles -GPOName $gpoName -FirewallProfiles $settings.FirewallProfiles + } + } + + # --- Firewall Rules --- + if ($settings.FirewallRules -and $settings.FirewallRules.Count -gt 0) { + if ($TestOnly) { + $fwDiffs = Compare-GPOFirewall -GPOName $gpoName -FirewallRules $settings.FirewallRules + $totalDiffs += @($fwDiffs).Count + } else { + Set-GPOFirewall -GPOName $gpoName -FirewallRules $settings.FirewallRules + } + } + + # --- AppLocker Policy --- + if ($settings.AppLockerPolicy) { + if ($TestOnly) { + $alDiffs = Compare-GPOAppLockerPolicy -GPOName $gpoName -AppLockerPolicy $settings.AppLockerPolicy + $totalDiffs += @($alDiffs).Count + } else { + Set-GPOAppLockerPolicy -GPOName $gpoName -AppLockerPolicy $settings.AppLockerPolicy + } + } + + # --- WDAC Policy --- + if ($settings.WDACPolicy) { + if ($TestOnly) { + $wdacDiffs = Compare-GPOWdacPolicy -GPOName $gpoName -WDACPolicy $settings.WDACPolicy -SourceDir $dir.FullName + $totalDiffs += @($wdacDiffs).Count + } else { + Set-GPOWdacPolicy -GPOName $gpoName -WDACPolicy $settings.WDACPolicy -SourceDir $dir.FullName + $bumpMachine = $true + } + } + + # --- Folder Redirection --- + if ($settings.FolderRedirection) { + if ($TestOnly) { + $frDiffs = Compare-GPOFolderRedirection -GPOName $gpoName -FolderRedirection $settings.FolderRedirection + $totalDiffs += @($frDiffs).Count + } else { + Set-GPOFolderRedirection -GPOName $gpoName -FolderRedirection $settings.FolderRedirection + $bumpUser = $true + } + } + + # --- Deferred Version Bump (single bump per GPO, scope-aware) --- + if (-not $TestOnly -and ($bumpMachine -or $bumpUser)) { + $scope = if ($bumpMachine -and $bumpUser) { 'Both' } + elseif ($bumpMachine) { 'Machine' } + else { 'User' } + Update-GPOVersion -GPOName $gpoName -Scope $scope + Write-Host " GPO version bumped ($scope)." -ForegroundColor Green + } + + Write-Host '' +} + +# ------------------------------------------------------------------- +# Summary +# ------------------------------------------------------------------- +if ($TestOnly) { + if ($totalDiffs -eq 0) { + Write-Host 'All GPO settings match desired state.' -ForegroundColor Green + } else { + Write-Host "$totalDiffs difference(s) found." -ForegroundColor Red + } + Write-Host 'Compliance test complete. No changes were made.' -ForegroundColor Yellow +} else { + Write-Host 'All GPO settings applied.' -ForegroundColor Green +} + +# ------------------------------------------------------------------- +# Optional: Trigger group policy refresh +# ------------------------------------------------------------------- +if ($GpUpdate -and -not $TestOnly) { + Write-Host '' + Write-Host 'Running gpupdate /force to process updated policies...' -ForegroundColor Cyan + $gpResult = gpupdate /force 2>&1 + Write-Host ($gpResult | Out-String) -ForegroundColor Gray + Write-Host 'Group Policy refresh complete.' -ForegroundColor Green +} elseif (-not $TestOnly) { + Write-Host '' + Write-Host 'TIP: Run with -GpUpdate to trigger gpupdate /force after applying.' -ForegroundColor DarkGray + Write-Host ' Or manually: gpupdate /force' -ForegroundColor DarkGray +} diff --git a/gpo/Get-UnmanagedGPOs.ps1 b/gpo/Get-UnmanagedGPOs.ps1 new file mode 100644 index 0000000..55c2c9f --- /dev/null +++ b/gpo/Get-UnmanagedGPOs.ps1 @@ -0,0 +1,135 @@ +# Get-UnmanagedGPOs.ps1 +# Discovers GPOs in AD that are not managed by the framework, +# and framework-defined GPOs that do not yet exist in AD. +# +# Usage: +# .\Get-UnmanagedGPOs.ps1 + +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$ScriptRoot = $PSScriptRoot + +. (Join-Path $ScriptRoot 'lib\GPOHelper.ps1') + +# ------------------------------------------------------------------- +# Discover managed GPO names from settings.ps1 files +# ------------------------------------------------------------------- +$gpoDirs = Get-ChildItem -Path $ScriptRoot -Directory | + Where-Object { Test-Path (Join-Path $_.FullName 'settings.ps1') } + +$managedNames = @{} +foreach ($dir in $gpoDirs) { + $settings = & (Join-Path $dir.FullName 'settings.ps1') + $managedNames[$settings.GPOName] = $dir.Name +} + +Write-Host "Framework manages $($managedNames.Count) GPO(s).`n" -ForegroundColor Cyan + +# ------------------------------------------------------------------- +# Enumerate all GPOs in AD +# ------------------------------------------------------------------- +$allGPOs = Get-GPO -All + +# ------------------------------------------------------------------- +# Build OU link map (check known OUs for GPO links) +# ------------------------------------------------------------------- +$domainDN = (Get-ADDomain).DistinguishedName +$checkTargets = @( + $domainDN + "OU=Domain Controllers,$domainDN" + "OU=ExampleUsers,$domainDN" + "OU=ExampleWorkstations,$domainDN" + "OU=ExampleServers,$domainDN" + "OU=ExampleAdmins,$domainDN" + "OU=ExampleAdminWorkstations,$domainDN" +) + +# Map GPO GUID -> list of linked OU names +$linkMap = @{} +foreach ($target in $checkTargets) { + try { + $inheritance = Get-GPInheritance -Target $target -ErrorAction Stop + } catch { + continue + } + + # Extract short OU name for display + $ouDisplay = if ($target -eq $domainDN) { + '(domain root)' + } else { + ($target -split ',')[0] -replace '^OU=', '' + } + + foreach ($link in $inheritance.GpoLinks) { + $gpoId = $link.GpoId.ToString() + if (-not $linkMap.ContainsKey($gpoId)) { + $linkMap[$gpoId] = @() + } + $linkMap[$gpoId] += $ouDisplay + } +} + +# ------------------------------------------------------------------- +# Section 1: Unmanaged GPOs (in AD but not in framework) +# ------------------------------------------------------------------- +$unmanaged = @() +foreach ($gpo in $allGPOs) { + if (-not $managedNames.ContainsKey($gpo.DisplayName)) { + $gpoId = $gpo.Id.ToString() + $linkedTo = if ($linkMap.ContainsKey($gpoId)) { + $linkMap[$gpoId] -join ', ' + } else { '(not linked)' } + + $unmanaged += [PSCustomObject]@{ + Name = $gpo.DisplayName + Id = "{$gpoId}" + CreationTime = $gpo.CreationTime.ToString('yyyy-MM-dd') + ModificationTime = $gpo.ModificationTime.ToString('yyyy-MM-dd') + LinkedTo = $linkedTo + GpoStatus = $gpo.GpoStatus.ToString() + } + } +} + +Write-Host '=== Unmanaged GPOs in AD ===' -ForegroundColor White +if ($unmanaged.Count -eq 0) { + Write-Host 'None found. All GPOs in AD are managed by the framework.' -ForegroundColor Green +} else { + Write-Host "$($unmanaged.Count) GPO(s) exist in AD without a settings.ps1:`n" -ForegroundColor Yellow + $unmanaged | Format-Table Name, Id, CreationTime, ModificationTime, LinkedTo, GpoStatus -AutoSize +} + +# ------------------------------------------------------------------- +# Section 2: Framework GPOs not in AD (defined but not created) +# ------------------------------------------------------------------- +$adNames = @{} +foreach ($gpo in $allGPOs) { + $adNames[$gpo.DisplayName] = $true +} + +$notInAD = @() +foreach ($name in $managedNames.Keys) { + if (-not $adNames.ContainsKey($name)) { + $notInAD += [PSCustomObject]@{ + GPOName = $name + ConfigDir = $managedNames[$name] + Status = 'Defined but not created in AD' + } + } +} + +Write-Host '=== Framework GPOs Not in AD ===' -ForegroundColor White +if ($notInAD.Count -eq 0) { + Write-Host 'None. All framework GPOs exist in AD.' -ForegroundColor Green +} else { + Write-Host "$($notInAD.Count) framework GPO(s) not yet created:`n" -ForegroundColor Yellow + $notInAD | Format-Table GPOName, ConfigDir, Status -AutoSize +} + +# ------------------------------------------------------------------- +# Summary +# ------------------------------------------------------------------- +Write-Host '' +Write-Host "Summary: $($unmanaged.Count) unmanaged, $($notInAD.Count) not in AD, $($managedNames.Count) managed." -ForegroundColor Cyan diff --git a/gpo/Restore-GPOBaseline.ps1 b/gpo/Restore-GPOBaseline.ps1 new file mode 100644 index 0000000..5ad6ff0 --- /dev/null +++ b/gpo/Restore-GPOBaseline.ps1 @@ -0,0 +1,78 @@ +# Restore-GPOBaseline.ps1 +# Interactive restore script for GPO backups. +# +# Usage: +# .\Restore-GPOBaseline.ps1 # List all backups +# .\Restore-GPOBaseline.ps1 -GPOName 'Admins-01' # List backups for one GPO +# .\Restore-GPOBaseline.ps1 -GPOName 'Admins-01' -Timestamp '20260214-153000' # Restore + +[CmdletBinding()] +param( + [string]$GPOName, + [string]$Timestamp, + [switch]$Force +) + +$ErrorActionPreference = 'Stop' +$ScriptRoot = $PSScriptRoot + +. (Join-Path $ScriptRoot 'lib\GPOHelper.ps1') + +# ------------------------------------------------------------------- +# List mode (no Timestamp specified) +# ------------------------------------------------------------------- +if (-not $Timestamp) { + Write-Host 'Available GPO backups:' -ForegroundColor Cyan + Write-Host '' + + $backups = Get-GPOBackups -GPOName $GPOName + if ($backups.Count -eq 0) { + Write-Host 'No backups found.' -ForegroundColor Yellow + if ($GPOName) { + Write-Host "Run without -GPOName to see all GPOs." -ForegroundColor DarkGray + } + exit 0 + } + + $backups | Format-Table GPOName, Timestamp, Admin, Version -AutoSize + Write-Host '' + Write-Host 'To restore, run:' -ForegroundColor DarkGray + Write-Host " .\Restore-GPOBaseline.ps1 -GPOName '' -Timestamp ''" -ForegroundColor DarkGray + exit 0 +} + +# ------------------------------------------------------------------- +# Restore mode (Timestamp specified) +# ------------------------------------------------------------------- +if (-not $GPOName) { + Write-Host '-GPOName is required when specifying -Timestamp.' -ForegroundColor Red + exit 1 +} + +$safeName = $GPOName -replace '[^\w\-]', '_' +$backupRoot = Join-Path $ScriptRoot 'backups' +$backupPath = Join-Path $backupRoot "$safeName\$Timestamp" + +if (-not (Test-Path $backupPath)) { + Write-Host "Backup not found: $backupPath" -ForegroundColor Red + Write-Host 'Run without -Timestamp to see available backups.' -ForegroundColor DarkGray + exit 1 +} + +# Confirmation +if (-not $Force) { + $meta = Get-Content (Join-Path $backupPath 'metadata.json') -Raw | ConvertFrom-Json + Write-Host "About to restore GPO '$GPOName' to state from $($meta.Timestamp)" -ForegroundColor Yellow + Write-Host " Backup admin: $($meta.Admin)" -ForegroundColor Yellow + Write-Host " Backup version: $($meta.VersionNumber)" -ForegroundColor Yellow + Write-Host '' + $confirm = Read-Host 'Type YES to proceed' + if ($confirm -ne 'YES') { + Write-Host 'Aborted.' -ForegroundColor DarkGray + exit 0 + } +} + +Restore-GPOState -BackupPath $backupPath +Write-Host '' +Write-Host 'TIP: Run Apply-GPOBaseline.ps1 -TestOnly to verify the restored state.' -ForegroundColor DarkGray diff --git a/gpo/admins-01/README.md b/gpo/admins-01/README.md new file mode 100644 index 0000000..f3b184b --- /dev/null +++ b/gpo/admins-01/README.md @@ -0,0 +1,41 @@ +# Admins-01 GPO + +**GUID:** Auto-created on first `Apply-GPOBaseline.ps1` run +**Linked to:** `OU=ExampleAdmins,DC=example,DC=internal` +**Scope:** User Configuration (HKCU) -- Administrative Templates only + +This GPO applies to delegated administrator accounts in the ExampleAdmins OU. Unlike Users-01, it does NOT restrict access to management tools (regedit, cmd, Run, etc.). Instead it focuses on session security and accountability. + +## Settings + +### Session Security + +| Setting | Value | Effect | +|---|---|---| +| ScreenSaveActive | 1 | Enable screensaver (required for lock timeout) | +| ScreenSaveTimeOut | 600 | Lock screen after 10 minutes idle | +| ScreenSaverIsSecure | 1 | Require password to unlock | + +### Accountability + +| Setting | Value | Effect | +|---|---|---| +| EnableScriptBlockLogging | 1 | Logs all PowerShell script blocks to event log | +| EnableTranscripting | 1 | Full transcript of all PowerShell sessions | + +### Taskbar Cleanup + +| Setting | Value | Effect | +|---|---|---| +| TurnOffWindowsCopilot | 1 | Disables Windows Copilot | +| TaskbarDa | 0 | Hides Widgets | +| SearchboxTaskbarMode | 0 | Hides Search box | + +## Design Rationale + +Admins need unrestricted access to system tools. The policies here enforce: +1. **Session security** -- unattended admin sessions auto-lock after 10 minutes +2. **Audit trail** -- all PowerShell activity is logged for forensic review +3. **Clean workspace** -- distracting taskbar elements removed + +Actual admin privileges come from membership in the DelegatedAdmins security group, not from this GPO. diff --git a/gpo/admins-01/settings.ps1 b/gpo/admins-01/settings.ps1 new file mode 100644 index 0000000..480466d --- /dev/null +++ b/gpo/admins-01/settings.ps1 @@ -0,0 +1,98 @@ +# Admins-01 -- Settings Declaration +# Linked to: OU=ExampleAdmins,DC=example,DC=internal +# +# This GPO targets delegated administrator accounts. +# No desktop restrictions -- admins need access to management tools. +# Focus is on accountability (logging) and session security (screen lock). +# All settings are User Configuration (HKCU). + +@{ + GPOName = 'Admins-01' + Description = 'Admin account policy -- session lock, PS logging, taskbar cleanup' + + DisableComputerConfiguration = $true + + LinkTo = 'OU=ExampleAdmins,DC=example,DC=internal' + + # No security policy settings -- admin privileges come from group membership, not GPO + SecurityPolicy = @{} + + RegistrySettings = @( + + # ============================================================= + # Session Security -- Screensaver Lock + # ============================================================= + + # Enable screensaver (required for timeout lock to work) + @{ + Key = 'HKCU\Software\Policies\Microsoft\Windows\Control Panel\Desktop' + ValueName = 'ScreenSaveActive' + Type = 'String' + Value = '1' + } + + # Screensaver timeout: 10 minutes (600 seconds) + @{ + Key = 'HKCU\Software\Policies\Microsoft\Windows\Control Panel\Desktop' + ValueName = 'ScreenSaveTimeOut' + Type = 'String' + Value = '600' + } + + # Password-protect the screensaver (require unlock) + @{ + Key = 'HKCU\Software\Policies\Microsoft\Windows\Control Panel\Desktop' + ValueName = 'ScreenSaverIsSecure' + Type = 'String' + Value = '1' + } + + # ============================================================= + # Accountability -- PowerShell Logging + # ============================================================= + + # Enable PowerShell script block logging + @{ + Key = 'HKCU\Software\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging' + ValueName = 'EnableScriptBlockLogging' + Type = 'DWord' + Value = 1 + } + + # Enable PowerShell transcription + @{ + Key = 'HKCU\Software\Policies\Microsoft\Windows\PowerShell\Transcription' + ValueName = 'EnableTranscripting' + Type = 'DWord' + Value = 1 + } + + # ============================================================= + # Taskbar Cleanup + # ============================================================= + + # Disable Windows Copilot + @{ + Key = 'HKCU\Software\Policies\Microsoft\Windows\WindowsCopilot' + ValueName = 'TurnOffWindowsCopilot' + Type = 'DWord' + Value = 1 + } + + # Hide Widgets on taskbar + @{ + Key = 'HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' + ValueName = 'TaskbarDa' + Type = 'DWord' + Value = 0 + } + + # Hide Search box on taskbar (0=Hidden, 1=Icon, 2=Full box) + @{ + Key = 'HKCU\Software\Microsoft\Windows\CurrentVersion\Search' + ValueName = 'SearchboxTaskbarMode' + Type = 'DWord' + Value = 0 + } + ) +} diff --git a/gpo/adminworkstations-01/Install-RSAT.ps1 b/gpo/adminworkstations-01/Install-RSAT.ps1 new file mode 100644 index 0000000..d19110c --- /dev/null +++ b/gpo/adminworkstations-01/Install-RSAT.ps1 @@ -0,0 +1,45 @@ +# Install-RSAT.ps1 +# GPO Startup Script — installs RSAT Features on Demand if not already present. +# Runs as SYSTEM at computer startup. Idempotent — safe to run repeatedly. +# Requires internet access (downloads from Windows Update). + +$logFile = 'C:\PSlogs\RSAT-Install.log' + +# Ensure log and transcript directories exist +$logDir = Split-Path $logFile -Parent +if (-not (Test-Path $logDir)) { + New-Item -ItemType Directory -Path $logDir -Force | Out-Null +} +$transcriptDir = 'C:\PSlogs\Transcripts' +if (-not (Test-Path $transcriptDir)) { + New-Item -ItemType Directory -Path $transcriptDir -Force | Out-Null +} + +function Write-Log { + param([string]$Message) + $ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + "$ts $Message" | Out-File -Append -FilePath $logFile +} + +Write-Log '=== RSAT Install Check Starting ===' + +$rsatFeatures = Get-WindowsCapability -Name 'Rsat*' -Online | + Where-Object { $_.State -ne 'Installed' } + +if ($rsatFeatures.Count -eq 0) { + Write-Log 'All RSAT features already installed. Nothing to do.' +} else { + Write-Log "$($rsatFeatures.Count) RSAT feature(s) not installed. Installing..." + foreach ($feature in $rsatFeatures) { + Write-Log " Installing: $($feature.Name)" + try { + Add-WindowsCapability -Online -Name $feature.Name -ErrorAction Stop | Out-Null + Write-Log " [OK] $($feature.Name)" + } catch { + Write-Log " [FAILED] $($feature.Name): $($_.Exception.Message)" + } + } + Write-Log 'RSAT installation pass complete.' +} + +Write-Log '=== RSAT Install Check Finished ===' diff --git a/gpo/adminworkstations-01/README.md b/gpo/adminworkstations-01/README.md new file mode 100644 index 0000000..215e252 --- /dev/null +++ b/gpo/adminworkstations-01/README.md @@ -0,0 +1,111 @@ +# AdminWorkstations-01 GPO + +Privileged Access Workstation (PAW) policy for admin endpoints in the ExampleAdminWorkstations OU. + +## Linked To + +`OU=ExampleAdminWorkstations,DC=example,DC=internal` + +## Design + +Builds on the same foundation as Workstations-01 but with: + +- **Full audit coverage** -- every category audits both success and failure (including process tracking) +- **PowerShell transcription** -- complete session recording to `C:\PSlogs\Transcripts` for forensics +- **Module logging** -- all PowerShell modules logged +- **Command line in process creation** -- Event ID 4688 includes full command line +- **Larger event logs** -- 2x workstation sizes to accommodate heavier admin activity +- **Tighter inactivity timeout** -- 10 min vs 15 min for workstations + +## WMI Filter + +| Property | Value | +|---|---| +| Name | Workstations Only | +| Query | `SELECT * FROM Win32_OperatingSystem WHERE ProductType = 1` | + +Defense-in-depth: ensures this GPO only applies to workstation operating systems. + +## Restricted Groups + +| Local Group | Enforced Members | +|---|---| +| BUILTIN\Administrators | Domain Admins, MasterAdmins | + +Any locally-added administrator accounts are removed on next GPO refresh. + +## Security Policy Settings (GptTmpl.inf) + +### System Access + +| Setting | Value | Effect | +|---|---|---| +| EnableGuestAccount | 0 | Local guest account disabled | + +### Event Audit + +| Setting | Value | Effect | +|---|---|---| +| AuditSystemEvents | 3 | Success + Failure | +| AuditLogonEvents | 3 | Success + Failure | +| AuditObjectAccess | 3 | Success + Failure | +| AuditPrivilegeUse | 3 | Success + Failure | +| AuditPolicyChange | 3 | Success + Failure | +| AuditAccountManage | 3 | Success + Failure | +| AuditProcessTracking | 1 | Success | +| AuditDSAccess | 0 | None (not a DC) | +| AuditAccountLogon | 3 | Success + Failure | + +### Registry Values (Security Options) + +| Setting | Value | Effect | +|---|---|---| +| InactivityTimeoutSecs | 600 | Auto-lock after 10 minutes | +| DontDisplayLastUserName | 1 | Don't show last user at login screen | +| DisableCAD | 0 | Require Ctrl+Alt+Del | +| LocalAccountTokenFilterPolicy | 1 | Allow unfiltered admin tokens over WinRM (enables remote GPO/AD management without RDP) | + +## Registry Settings (Administrative Templates) + +### Autorun / Autoplay + +| Key | ValueName | Value | Effect | +|---|---|---|---| +| Policies\Explorer | NoDriveTypeAutoRun | 255 | Disable autorun on all drives | +| Policies\Explorer | NoAutorun | 1 | Disable autoplay | + +### Windows Update + +| Key | ValueName | Value | Effect | +|---|---|---|---| +| WindowsUpdate\AU | NoAutoUpdate | 0 | Enable automatic updates | +| WindowsUpdate\AU | AUOptions | 4 | Auto download + schedule install | +| WindowsUpdate\AU | ScheduledInstallDay | 0 | Every day | +| WindowsUpdate\AU | ScheduledInstallTime | 3 | 3:00 AM | + +### Logging & Auditing + +| Key | ValueName | Value | Effect | +|---|---|---|---| +| PowerShell\ScriptBlockLogging | EnableScriptBlockLogging | 1 | Log all script blocks | +| PowerShell\Transcription | EnableTranscripting | 1 | Record full PS sessions | +| PowerShell\Transcription | OutputDirectory | C:\PSlogs\Transcripts | Transcript save location | +| PowerShell\Transcription | EnableInvocationHeader | 1 | Timestamp per command | +| PowerShell\ModuleLogging | EnableModuleLogging | 1 | Log all module activity | +| PowerShell\ModuleLogging\ModuleNames | * | * | All modules | +| System\Audit | ProcessCreationIncludeCmdLine_Enabled | 1 | Command line in Event 4688 | + +### Event Log Sizes + +| Log | Size | vs. Workstations-01 | +|---|---|---| +| Application | 64 MB | 2x | +| Security | 256 MB | ~1.3x | +| System | 64 MB | 2x | +| PowerShell | 64 MB | new | + +### Remote Desktop + +| Key | ValueName | Value | Effect | +|---|---|---|---| +| Terminal Services | UserAuthentication | 1 | Require NLA | diff --git a/gpo/adminworkstations-01/WDACPolicy.xml b/gpo/adminworkstations-01/WDACPolicy.xml new file mode 100644 index 0000000..d099943 --- /dev/null +++ b/gpo/adminworkstations-01/WDACPolicy.xml @@ -0,0 +1,169 @@ + + + + 10.0.1.0 + {7BE95702-9AD4-402C-BCCE-87D8587E0F7D} + {7BE95702-9AD4-402C-BCCE-87D8587E0F7D} + {2E07F7E4-194C-4D20-B7C9-6F44A6C5A234} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + Example-WDAC-AllowMicrosoft-Audit + + + 7BE95702 + + + diff --git a/gpo/adminworkstations-01/settings.ps1 b/gpo/adminworkstations-01/settings.ps1 new file mode 100644 index 0000000..67c688c --- /dev/null +++ b/gpo/adminworkstations-01/settings.ps1 @@ -0,0 +1,470 @@ +# AdminWorkstations-01 -- Settings Declaration +# Linked to: OU=ExampleAdminWorkstations,DC=example,DC=internal +# +# Privileged Access Workstation (PAW) policy. Inherits the spirit of Workstations-01 +# but with stricter logging, tighter lockout, and enhanced forensic capability. +# All settings are Computer Configuration (HKLM). + +@{ + GPOName = 'AdminWorkstations-01' + Description = 'Privileged Access Workstation (PAW) -- enhanced logging, strict lockout, forensic capability' + + DisableUserConfiguration = $true + + LinkTo = 'OU=ExampleAdminWorkstations,DC=example,DC=internal' + + # Defense-in-depth: only apply to workstation OS (ProductType 1). + # Prevents misapplication if a server object lands in the wrong OU. + WMIFilter = @{ + Name = 'Workstations Only' + Description = 'Targets workstation operating systems (ProductType = 1)' + Query = "SELECT * FROM Win32_OperatingSystem WHERE ProductType = 1" + } + + # Lock down local Administrators to Domain Admins + MasterAdmins only. + # Any unauthorized additions are removed on next GPO refresh. + RestrictedGroups = @{ + 'BUILTIN\Administrators' = @{ + Members = @('EXAMPLE\Domain Admins', 'EXAMPLE\MasterAdmins') + } + } + + SecurityPolicy = @{ + + 'System Access' = [ordered]@{ + EnableGuestAccount = 0 + } + + 'Event Audit' = [ordered]@{ + # Everything audited -- admin machines need full visibility + AuditSystemEvents = 3 # Success + Failure + AuditLogonEvents = 3 # Success + Failure + AuditObjectAccess = 3 # Success + Failure + AuditPrivilegeUse = 3 # Success + Failure + AuditPolicyChange = 3 # Success + Failure + AuditAccountManage = 3 # Success + Failure + AuditProcessTracking = 1 # Success (track what admins run) + AuditDSAccess = 0 # No auditing (not a DC) + AuditAccountLogon = 3 # Success + Failure + } + + 'Registry Values' = [ordered]@{ + # Interactive logon: Machine inactivity limit -- 10 min (stricter than workstations) + 'MACHINE\Software\Microsoft\Windows\CurrentVersion\Policies\System\InactivityTimeoutSecs' = '4,600' + + # Interactive logon: Don't display last signed-in user + 'MACHINE\Software\Microsoft\Windows\CurrentVersion\Policies\System\DontDisplayLastUserName' = '4,1' + + # Interactive logon: Require CTRL+ALT+DEL + 'MACHINE\Software\Microsoft\Windows NT\CurrentVersion\Winlogon\DisableCAD' = '4,0' + + # Allow admin accounts to receive unfiltered tokens over WinRM (e.g. Invoke-Command) + # Required for remote GPO/AD baseline management without RDP to DC + 'MACHINE\Software\Microsoft\Windows\CurrentVersion\Policies\System\LocalAccountTokenFilterPolicy' = '4,1' + } + } + + # ================================================================= + # Windows Firewall -- default-deny inbound, allow management traffic + # PAWs need inbound WinRM so the DC can manage them remotely. + # ================================================================= + + FirewallProfiles = @{ + Domain = @{ Enabled = $true; DefaultInboundAction = 'Block'; DefaultOutboundAction = 'Allow' } + Private = @{ Enabled = $true; DefaultInboundAction = 'Block'; DefaultOutboundAction = 'Allow' } + Public = @{ Enabled = $true; DefaultInboundAction = 'Block'; DefaultOutboundAction = 'Allow' } + } + + FirewallRules = @( + # WinRM -- remote management + @{ + DisplayName = 'Allow WinRM HTTP (Inbound)' + Direction = 'Inbound' + Action = 'Allow' + Protocol = 'TCP' + LocalPort = '5985' + Profile = 'Domain' + Description = 'Windows Remote Management (HTTP) for domain management' + } + + # RDP -- remote assistance + @{ + DisplayName = 'Allow RDP (Inbound)' + Direction = 'Inbound' + Action = 'Allow' + Protocol = 'TCP' + LocalPort = '3389' + Profile = 'Domain' + Description = 'Remote Desktop Protocol for domain-authenticated sessions' + } + + # ICMP Echo -- network troubleshooting + @{ + DisplayName = 'Allow ICMP Echo Request (Inbound)' + Direction = 'Inbound' + Action = 'Allow' + Protocol = 'ICMPv4' + Profile = 'Domain' + Description = 'ICMP echo for ping-based health monitoring' + } + ) + + # ================================================================= + # Advanced Audit Policy -- granular subcategory-level auditing + # Overrides the legacy Event Audit above with 53 subcategories. + # PAWs need full visibility into admin actions. + # ================================================================= + + AdvancedAuditPolicy = @{ + # System + 'Security State Change' = 'Success and Failure' + 'Security System Extension' = 'Success and Failure' + 'System Integrity' = 'Success and Failure' + + # Logon/Logoff + 'Logon' = 'Success and Failure' + 'Logoff' = 'Success' + 'Account Lockout' = 'Success' + 'Special Logon' = 'Success and Failure' + 'Other Logon/Logoff Events' = 'Success and Failure' + 'Group Membership' = 'Success' + + # Object Access + 'File System' = 'Success and Failure' + 'Registry' = 'Success and Failure' + 'SAM' = 'Success and Failure' + 'Removable Storage' = 'Success and Failure' + + # Privilege Use + 'Sensitive Privilege Use' = 'Success and Failure' + + # Detailed Tracking -- track what admins run + 'Process Creation' = 'Success and Failure' + 'Process Termination' = 'Success' + 'DPAPI Activity' = 'Success and Failure' + 'Plug and Play Events' = 'Success' + + # Policy Change + 'Audit Policy Change' = 'Success and Failure' + 'Authentication Policy Change' = 'Success' + 'MPSSVC Rule-Level Policy Change' = 'Success and Failure' + + # Account Management + 'User Account Management' = 'Success and Failure' + 'Security Group Management' = 'Success and Failure' + + # Account Logon + 'Credential Validation' = 'Success and Failure' + 'Kerberos Authentication Service' = 'Success and Failure' + 'Kerberos Service Ticket Operations' = 'Success and Failure' + } + + # ================================================================= + # WDAC -- AllowMicrosoft baseline in audit mode + # Trusts all Microsoft-signed binaries and WHQL drivers. + # Logs CodeIntegrity event 3076 for anything that would be blocked. + # Review: Get-WinEvent 'Microsoft-Windows-CodeIntegrity/Operational' | ? Id -eq 3076 + # ================================================================= + + WDACPolicy = @{ + PolicyFile = 'WDACPolicy.xml' + } + + Scripts = @{ + MachineStartup = @( + @{ + Source = 'Install-RSAT.ps1' + Parameters = '' + } + ) + } + + # ================================================================= + # AppLocker -- audit mode (logs violations, does not block) + # Same baseline as Workstations-01 but deployed separately so PAWs + # can be tightened to Enabled independently once audit logs are clean. + # ================================================================= + + AppLockerPolicy = @{ + Exe = @{ + EnforcementMode = 'AuditOnly' + Rules = @( + @{ + Type = 'Publisher' + Name = 'Allow Microsoft-signed executables' + Action = 'Allow' + User = 'Everyone' + Publisher = 'O=MICROSOFT CORPORATION, L=REDMOND, S=WASHINGTON, C=US' + Product = '*' + Binary = '*' + } + @{ + Type = 'Path' + Name = 'Allow executables in Program Files' + Action = 'Allow' + User = 'Everyone' + Path = '%PROGRAMFILES%\*' + } + @{ + Type = 'Path' + Name = 'Allow executables in Windows directory' + Action = 'Allow' + User = 'Everyone' + Path = '%WINDIR%\*' + } + @{ + Type = 'Path' + Name = 'Allow administrators unrestricted' + Action = 'Allow' + User = 'BUILTIN\Administrators' + Path = '*' + } + ) + } + Msi = @{ + EnforcementMode = 'AuditOnly' + Rules = @( + @{ + Type = 'Publisher' + Name = 'Allow Microsoft-signed installers' + Action = 'Allow' + User = 'Everyone' + Publisher = 'O=MICROSOFT CORPORATION, L=REDMOND, S=WASHINGTON, C=US' + Product = '*' + Binary = '*' + } + @{ + Type = 'Path' + Name = 'Allow administrators unrestricted' + Action = 'Allow' + User = 'BUILTIN\Administrators' + Path = '*' + } + ) + } + Script = @{ + EnforcementMode = 'AuditOnly' + Rules = @( + @{ + Type = 'Path' + Name = 'Allow scripts in Windows directory' + Action = 'Allow' + User = 'Everyone' + Path = '%WINDIR%\*' + } + @{ + Type = 'Path' + Name = 'Allow scripts in Program Files' + Action = 'Allow' + User = 'Everyone' + Path = '%PROGRAMFILES%\*' + } + @{ + Type = 'Path' + Name = 'Allow administrators unrestricted' + Action = 'Allow' + User = 'BUILTIN\Administrators' + Path = '*' + } + ) + } + } + + RegistrySettings = @( + + # ============================================================= + # Autorun / Autoplay + # ============================================================= + + @{ + Key = 'HKLM\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer' + ValueName = 'NoDriveTypeAutoRun' + Type = 'DWord' + Value = 255 + } + + @{ + Key = 'HKLM\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer' + ValueName = 'NoAutorun' + Type = 'DWord' + Value = 1 + } + + # ============================================================= + # Windows Update + # ============================================================= + + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\WindowsUpdate\AU' + ValueName = 'NoAutoUpdate' + Type = 'DWord' + Value = 0 + } + + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\WindowsUpdate\AU' + ValueName = 'AUOptions' + Type = 'DWord' + Value = 4 + } + + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\WindowsUpdate\AU' + ValueName = 'ScheduledInstallDay' + Type = 'DWord' + Value = 0 + } + + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\WindowsUpdate\AU' + ValueName = 'ScheduledInstallTime' + Type = 'DWord' + Value = 3 + } + + # ============================================================= + # Logging & Auditing -- Enhanced for admin machines + # ============================================================= + + # PowerShell script block logging + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging' + ValueName = 'EnableScriptBlockLogging' + Type = 'DWord' + Value = 1 + } + + # PowerShell transcription -- full session recording for all users on this machine + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\PowerShell\Transcription' + ValueName = 'EnableTranscripting' + Type = 'DWord' + Value = 1 + } + + # Transcription output directory + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\PowerShell\Transcription' + ValueName = 'OutputDirectory' + Type = 'String' + Value = 'C:\PSlogs\Transcripts' + } + + # Include invocation headers in transcripts (timestamps per command) + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\PowerShell\Transcription' + ValueName = 'EnableInvocationHeader' + Type = 'DWord' + Value = 1 + } + + # PowerShell module logging -- log all module activity + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\PowerShell\ModuleLogging' + ValueName = 'EnableModuleLogging' + Type = 'DWord' + Value = 1 + } + + # Log all modules (wildcard) + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\PowerShell\ModuleLogging\ModuleNames' + ValueName = '*' + Type = 'String' + Value = '*' + } + + # Command line in process creation events (Event ID 4688) + @{ + Key = 'HKLM\Software\Microsoft\Windows\CurrentVersion\Policies\System\Audit' + ValueName = 'ProcessCreationIncludeCmdLine_Enabled' + Type = 'DWord' + Value = 1 + } + + # Event log sizes -- larger than workstations (admin activity generates more events) + + # Application log: 64 MB + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\EventLog\Application' + ValueName = 'MaxSize' + Type = 'DWord' + Value = 65536 + } + + # Security log: 256 MB + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\EventLog\Security' + ValueName = 'MaxSize' + Type = 'DWord' + Value = 262144 + } + + # System log: 64 MB + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\EventLog\System' + ValueName = 'MaxSize' + Type = 'DWord' + Value = 65536 + } + + # PowerShell Operational log: 64 MB + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\EventLog\Windows PowerShell' + ValueName = 'MaxSize' + Type = 'DWord' + Value = 65536 + } + + # ============================================================= + # Remote Desktop + # ============================================================= + + # Require NLA for RDP + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows NT\Terminal Services' + ValueName = 'UserAuthentication' + Type = 'DWord' + Value = 1 + } + + # ============================================================= + # Windows Defender Exclusions -- IDE Performance + # ============================================================= + # Real-time scanning of build artifacts and caches degrades IDE + # build and startup performance. These path exclusions prevent + # Defender from scanning frequently-written IDE directories. + + # Enable the path exclusions policy + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows Defender\Exclusions' + ValueName = 'Exclusions_Paths' + Type = 'DWord' + Value = 1 + } + + # JetBrains IDE settings and configuration + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows Defender\Exclusions\Paths' + ValueName = '%APPDATA%\JetBrains' + Type = 'String' + Value = '0' + } + + # JetBrains IDE caches, indexes, and Toolbox binaries + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows Defender\Exclusions\Paths' + ValueName = '%LOCALAPPDATA%\JetBrains' + Type = 'String' + Value = '0' + } + + # Gradle cache directory + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows Defender\Exclusions\Paths' + ValueName = '%USERPROFILE%\.gradle' + Type = 'String' + Value = '0' + } + ) +} diff --git a/gpo/default-domain-controller/DefaultDCPolicy.ps1 b/gpo/default-domain-controller/DefaultDCPolicy.ps1 new file mode 100644 index 0000000..a1dc632 --- /dev/null +++ b/gpo/default-domain-controller/DefaultDCPolicy.ps1 @@ -0,0 +1,141 @@ +# DefaultDCPolicy.ps1 +# DSC Configuration: Default Domain Controllers Policy for example.internal +# +# SINGLE SOURCE OF TRUTH: All values are read from settings.ps1. +# Do NOT hardcode policy values here -- edit settings.ps1 instead. +# +# These settings define who can perform privileged operations on DCs +# and how DC network communications are secured. + +# Load helpers for SID resolution and registry value parsing +. (Join-Path $PSScriptRoot '..\lib\GPOHelper.ps1') + +# Load the authoritative settings +$dcSettings = & (Join-Path $PSScriptRoot 'settings.ps1') +$privRights = $dcSettings.SecurityPolicy['Privilege Rights'] +$regValues = $dcSettings.SecurityPolicy['Registry Values'] + +# --------------------------------------------------------------- +# Privilege Rights: Se* constant -> DSC policy name mapping +# --------------------------------------------------------------- +$privilegeMap = @{ + 'SeAssignPrimaryTokenPrivilege' = 'Replace_a_process_level_token' + 'SeAuditPrivilege' = 'Generate_security_audits' + 'SeDebugPrivilege' = 'Debug_programs' + 'SeBackupPrivilege' = 'Back_up_files_and_directories' + 'SeRestorePrivilege' = 'Restore_files_and_directories' + 'SeBatchLogonRight' = 'Log_on_as_a_batch_job' + 'SeInteractiveLogonRight' = 'Allow_log_on_locally' + 'SeRemoteInteractiveLogonRight' = 'Allow_log_on_through_Remote_Desktop_Services' + 'SeNetworkLogonRight' = 'Access_this_computer_from_the_network' + 'SeChangeNotifyPrivilege' = 'Bypass_traverse_checking' + 'SeMachineAccountPrivilege' = 'Add_workstations_to_domain' + 'SeEnableDelegationPrivilege' = 'Enable_computer_and_user_accounts_to_be_trusted_for_delegation' + 'SeCreatePagefilePrivilege' = 'Create_a_pagefile' + 'SeIncreaseBasePriorityPrivilege' = 'Increase_scheduling_priority' + 'SeIncreaseQuotaPrivilege' = 'Adjust_memory_quotas_for_a_process' + 'SeLoadDriverPrivilege' = 'Load_and_unload_device_drivers' + 'SeProfileSingleProcessPrivilege' = 'Profile_single_process' + 'SeSystemProfilePrivilege' = 'Profile_system_performance' + 'SeRemoteShutdownPrivilege' = 'Force_shutdown_from_a_remote_system' + 'SeShutdownPrivilege' = 'Shut_down_the_system' + 'SeSecurityPrivilege' = 'Manage_auditing_and_security_log' + 'SeTakeOwnershipPrivilege' = 'Take_ownership_of_files_or_other_objects' + 'SeSystemEnvironmentPrivilege' = 'Modify_firmware_environment_values' + 'SeSystemTimePrivilege' = 'Change_the_system_time' + 'SeUndockPrivilege' = 'Remove_computer_from_docking_station' +} + +# Build DSC-friendly privilege list: resolve SIDs to NTAccount names +$dscPrivileges = @() +foreach ($seConst in $privRights.Keys) { + if (-not $privilegeMap.ContainsKey($seConst)) { + Write-Warning "Unknown privilege constant '$seConst' -- no DSC mapping. Skipping." + continue + } + + $dscPrivileges += @{ + ResourceName = $seConst -replace '^Se', '' + Policy = $privilegeMap[$seConst] + Identity = Resolve-SIDsToNames $privRights[$seConst] + } +} + +# --------------------------------------------------------------- +# Registry Values -> SecurityOption mapping (semantic) +# The mapping from DWORD values to DSC enum strings is +# setting-specific and must be maintained here. +# --------------------------------------------------------------- +$dcRegToSecOption = @{ + 'LDAPServerIntegrity' = @{ + DscProperty = 'Domain_controller_LDAP_server_signing_requirements' + ValueMap = @{ 1 = 'None'; 2 = 'Require signing' } + } + 'RequireSignOrSeal' = @{ + DscProperty = 'Domain_member_Digitally_encrypt_or_sign_secure_channel_data_always' + ValueMap = @{ 0 = 'Disabled'; 1 = 'Enabled' } + } + 'RequireSecuritySignature' = @{ + DscProperty = 'Microsoft_network_server_Digitally_sign_communications_always' + ValueMap = @{ 0 = 'Disabled'; 1 = 'Enabled' } + } + 'EnableSecuritySignature' = @{ + DscProperty = 'Microsoft_network_server_Digitally_sign_communications_if_client_agrees' + ValueMap = @{ 0 = 'Disabled'; 1 = 'Enabled' } + } +} + +# Extract short key name from full registry paths and map to DSC values +$dscSecOptions = @{} +foreach ($regPath in $regValues.Keys) { + $shortKey = ($regPath -split '\\')[-1] + + if ($dcRegToSecOption.ContainsKey($shortKey)) { + $mapping = $dcRegToSecOption[$shortKey] + $numValue = Get-GptTmplRegValue $regValues[$regPath] + $dscValue = $mapping.ValueMap[$numValue] + + if ($null -eq $dscValue) { + throw "No DSC value mapping for $shortKey = $numValue" + } + + $dscSecOptions[$mapping.DscProperty] = $dscValue + } +} + +# --------------------------------------------------------------- +# DSC Configuration +# --------------------------------------------------------------- +Configuration DefaultDCPolicy +{ + Import-DscResource -ModuleName SecurityPolicyDsc + + Node 'localhost' + { + # =============================================================== + # User Rights Assignments (generated from settings.ps1) + # Force = $true means DSC controls the full list exclusively. + # Any identities not listed will be removed from the privilege. + # =============================================================== + foreach ($priv in $dscPrivileges) { + UserRightsAssignment $priv.ResourceName + { + Policy = $priv.Policy + Identity = $priv.Identity + Force = $true + } + } + + # =============================================================== + # Security Options (generated from settings.ps1 Registry Values) + # =============================================================== + SecurityOption 'DCSecurityOptions' + { + Name = 'DCSecurityOptions' + Domain_controller_LDAP_server_signing_requirements = $dscSecOptions['Domain_controller_LDAP_server_signing_requirements'] + Domain_member_Digitally_encrypt_or_sign_secure_channel_data_always = $dscSecOptions['Domain_member_Digitally_encrypt_or_sign_secure_channel_data_always'] + Microsoft_network_server_Digitally_sign_communications_always = $dscSecOptions['Microsoft_network_server_Digitally_sign_communications_always'] + Microsoft_network_server_Digitally_sign_communications_if_client_agrees = $dscSecOptions['Microsoft_network_server_Digitally_sign_communications_if_client_agrees'] + } + } +} diff --git a/gpo/default-domain-controller/settings.ps1 b/gpo/default-domain-controller/settings.ps1 new file mode 100644 index 0000000..4f9096f --- /dev/null +++ b/gpo/default-domain-controller/settings.ps1 @@ -0,0 +1,154 @@ +# Default Domain Controllers Policy -- Settings Declaration +# GPO GUID: {6AC1786C-016F-11D2-945F-00C04FB984F9} (built-in, same on all domains) +# Linked to: OU=Domain Controllers,DC=example,DC=internal +# +# This GPO controls user rights assignments and security options for domain controllers. +# Privilege Rights use *SID notation as required by GptTmpl.inf format. + +# Resolve custom group SIDs at evaluation time so they stay correct if groups are recreated +$masterAdminsSID = (Get-ADGroup -Identity 'MasterAdmins').SID.Value + +@{ + GPOName = 'Default Domain Controllers Policy' + Description = 'User rights assignments, security options, and signing requirements for domain controllers' + + DisableUserConfiguration = $true + + # Already linked at domain creation + LinkTo = $null + + SecurityPolicy = @{ + + 'Registry Values' = [ordered]@{ + # LDAP server signing: 1=None (signing supported but not required) + 'MACHINE\System\CurrentControlSet\Services\NTDS\Parameters\LDAPServerIntegrity' = '4,1' + # Secure channel: always encrypt or sign + 'MACHINE\System\CurrentControlSet\Services\Netlogon\Parameters\RequireSignOrSeal' = '4,1' + # SMB server: always require signing + 'MACHINE\System\CurrentControlSet\Services\LanManServer\Parameters\RequireSecuritySignature' = '4,1' + # SMB server: sign if client agrees + 'MACHINE\System\CurrentControlSet\Services\LanManServer\Parameters\EnableSecuritySignature' = '4,1' + } + + 'Privilege Rights' = [ordered]@{ + # --- Token and Process Privileges --- + + # Replace a process-level token + SeAssignPrimaryTokenPrivilege = '*S-1-5-20,*S-1-5-19' + # NETWORK SERVICE, LOCAL SERVICE + + # Generate security audits + SeAuditPrivilege = '*S-1-5-99-216390572-1995538116-3857911515-2404958512-2623887229,*S-1-5-20,*S-1-5-19' + # PrintSpoolerService, NETWORK SERVICE, LOCAL SERVICE + + # Debug programs + SeDebugPrivilege = '*S-1-5-32-544' + # Administrators + + # --- Backup and Restore --- + + # Back up files and directories + SeBackupPrivilege = '*S-1-5-32-549,*S-1-5-32-551,*S-1-5-32-544' + # Server Operators, Backup Operators, Administrators + + # Restore files and directories + SeRestorePrivilege = '*S-1-5-32-549,*S-1-5-32-551,*S-1-5-32-544' + # Server Operators, Backup Operators, Administrators + + # --- Logon Rights --- + + # Log on as a batch job + SeBatchLogonRight = '*S-1-5-32-559,*S-1-5-32-551,*S-1-5-32-544' + # Performance Log Users, Backup Operators, Administrators + + # Allow log on locally + SeInteractiveLogonRight = '*S-1-5-9,*S-1-5-32-550,*S-1-5-32-549,*S-1-5-32-548,*S-1-5-32-551,*S-1-5-32-544' + # Enterprise DCs, Print Operators, Server Operators, Account Operators, Backup Operators, Administrators + + # Allow log on through Remote Desktop Services + SeRemoteInteractiveLogonRight = "*S-1-5-32-544,*$masterAdminsSID" + # Administrators, MasterAdmins + + # Access this computer from the network + SeNetworkLogonRight = '*S-1-5-32-554,*S-1-5-9,*S-1-5-11,*S-1-5-32-544,*S-1-1-0' + # Pre-Windows 2000 Compatible Access, Enterprise DCs, Authenticated Users, Administrators, Everyone + + # Bypass traverse checking + SeChangeNotifyPrivilege = '*S-1-5-32-554,*S-1-5-11,*S-1-5-99-216390572-1995538116-3857911515-2404958512-2623887229,*S-1-5-32-544,*S-1-5-20,*S-1-5-19,*S-1-1-0' + # Pre-Windows 2000, Authenticated Users, PrintSpoolerService, Administrators, NETWORK SERVICE, LOCAL SERVICE, Everyone + + # --- Domain and Machine Management --- + + # Add workstations to domain + SeMachineAccountPrivilege = '*S-1-5-11' + # Authenticated Users + + # Enable delegation + SeEnableDelegationPrivilege = '*S-1-5-32-544' + # Administrators + + # --- System Privileges --- + + # Create a pagefile + SeCreatePagefilePrivilege = '*S-1-5-32-544' + # Administrators + + # Increase scheduling priority + SeIncreaseBasePriorityPrivilege = '*S-1-5-90-0,*S-1-5-32-544' + # Window Manager Group, Administrators + + # Adjust memory quotas for a process + SeIncreaseQuotaPrivilege = '*S-1-5-32-544,*S-1-5-20,*S-1-5-19' + # Administrators, NETWORK SERVICE, LOCAL SERVICE + + # Load and unload device drivers + SeLoadDriverPrivilege = '*S-1-5-32-550,*S-1-5-32-544' + # Print Operators, Administrators + + # Profile single process + SeProfileSingleProcessPrivilege = '*S-1-5-32-544' + # Administrators + + # Profile system performance + SeSystemProfilePrivilege = '*S-1-5-80-3139157870-2983391045-3678747466-658725712-1809340420,*S-1-5-32-544' + # WdiServiceHost, Administrators + + # --- Shutdown --- + + # Force shutdown from a remote system + SeRemoteShutdownPrivilege = '*S-1-5-32-549,*S-1-5-32-544' + # Server Operators, Administrators + + # Shut down the system + SeShutdownPrivilege = '*S-1-5-32-550,*S-1-5-32-549,*S-1-5-32-551,*S-1-5-32-544' + # Print Operators, Server Operators, Backup Operators, Administrators + + # --- Security and Audit --- + + # Manage auditing and security log + SeSecurityPrivilege = '*S-1-5-32-544' + # Administrators + + # Take ownership of files or other objects + SeTakeOwnershipPrivilege = '*S-1-5-32-544' + # Administrators + + # --- Environment and Hardware --- + + # Modify firmware environment values + SeSystemEnvironmentPrivilege = '*S-1-5-32-544' + # Administrators + + # Change the system time + SeSystemTimePrivilege = '*S-1-5-32-549,*S-1-5-32-544,*S-1-5-19' + # Server Operators, Administrators, LOCAL SERVICE + + # Remove computer from docking station + SeUndockPrivilege = '*S-1-5-32-544' + # Administrators + } + } + + # No registry-based (Administrative Template) settings in this GPO + RegistrySettings = @() +} diff --git a/gpo/default-domain/DefaultDomainPolicy.ps1 b/gpo/default-domain/DefaultDomainPolicy.ps1 new file mode 100644 index 0000000..91625a9 --- /dev/null +++ b/gpo/default-domain/DefaultDomainPolicy.ps1 @@ -0,0 +1,118 @@ +# DefaultDomainPolicy.ps1 +# DSC Configuration: Default Domain Policy for example.internal +# +# SINGLE SOURCE OF TRUTH: All values are read from settings.ps1. +# Do NOT hardcode policy values here -- edit settings.ps1 instead. +# +# When applied on a Domain Controller, these settings become the effective +# domain-wide policy -- matching what the Default Domain Policy GPO enforces. + +# Load helpers for registry value parsing +. (Join-Path $PSScriptRoot '..\lib\GPOHelper.ps1') + +# Load the authoritative settings +$domainSettings = & (Join-Path $PSScriptRoot 'settings.ps1') +$sysAccess = $domainSettings.SecurityPolicy['System Access'] +$kerberosPolicy = $domainSettings.SecurityPolicy['Kerberos Policy'] +$regValues = $domainSettings.SecurityPolicy['Registry Values'] + +# --- Value transforms --- +$toEnabledDisabled = { param($val) if ([int]$val -eq 1) { 'Enabled' } else { 'Disabled' } } + +# Account Policy +$dscMinPasswordLength = [int]$sysAccess['MinimumPasswordLength'] +$dscComplexity = & $toEnabledDisabled $sysAccess['PasswordComplexity'] +$dscMaxPasswordAge = [int]$sysAccess['MaximumPasswordAge'] +$dscMinPasswordAge = [int]$sysAccess['MinimumPasswordAge'] +$dscPasswordHistory = [int]$sysAccess['PasswordHistorySize'] +$dscReversibleEncryption = & $toEnabledDisabled $sysAccess['ClearTextPassword'] +$dscLockoutThreshold = [int]$sysAccess['LockoutBadCount'] + +# Lockout duration/reset only meaningful when threshold > 0 +$dscLockoutDuration = if ($dscLockoutThreshold -gt 0) { [int]$sysAccess['LockoutDuration'] } else { 0 } +$dscResetLockoutCount = if ($dscLockoutThreshold -gt 0) { [int]$sysAccess['ResetLockoutCount'] } else { 0 } + +# Security Options +$dscForceLogoff = & $toEnabledDisabled $sysAccess['ForceLogoffWhenHourExpire'] +$dscAnonNameLookup = & $toEnabledDisabled $sysAccess['LSAAnonymousNameLookup'] +$noLmHashValue = Get-GptTmplRegValue $regValues['MACHINE\System\CurrentControlSet\Control\Lsa\NoLMHash'] +$dscNoLmHash = & $toEnabledDisabled $noLmHashValue + +Configuration DefaultDomainPolicy +{ + Import-DscResource -ModuleName SecurityPolicyDsc + + Node 'localhost' + { + # --------------------------------------------------------------- + # Account Policy (Password + Lockout) + # Only one AccountPolicy resource is allowed per configuration. + # --------------------------------------------------------------- + AccountPolicy 'DomainAccountPolicy' + { + Name = 'DomainAccountPolicy' + Minimum_Password_Length = $dscMinPasswordLength + Password_must_meet_complexity_requirements = $dscComplexity + Maximum_Password_Age = $dscMaxPasswordAge + Minimum_Password_Age = $dscMinPasswordAge + Enforce_password_history = $dscPasswordHistory + Store_passwords_using_reversible_encryption = $dscReversibleEncryption + Account_lockout_threshold = $dscLockoutThreshold + Account_lockout_duration = $dscLockoutDuration + Reset_account_lockout_counter_after = $dscResetLockoutCount + } + + # --------------------------------------------------------------- + # Security Options + # --------------------------------------------------------------- + SecurityOption 'DomainSecurityOptions' + { + Name = 'DomainSecurityOptions' + Network_security_Do_not_store_LAN_Manager_hash_value_on_next_password_change = $dscNoLmHash + Network_security_Force_logoff_when_logon_hours_expire = $dscForceLogoff + Network_access_Allow_anonymous_SID_Name_translation = $dscAnonNameLookup + } + + # --------------------------------------------------------------- + # Kerberos Policy (validated via secedit -- no DSC resource exists) + # SecurityPolicyDsc does not support Kerberos settings. + # --------------------------------------------------------------- + Script 'KerberosPolicy' + { + GetScript = { @{ Result = 'Kerberos policy validation' } } + + SetScript = { + throw 'Kerberos policy remediation not supported via DSC. Use Apply-GPOBaseline.ps1 + gpupdate /force.' + } + + TestScript = { + $tempFile = [System.IO.Path]::GetTempFileName() + try { + secedit /export /cfg $tempFile /quiet | Out-Null + $content = Get-Content $tempFile -Raw + + $kerberosSettings = $using:kerberosPolicy + $compliant = $true + + foreach ($key in $kerberosSettings.Keys) { + if ($content -match "$key\s*=\s*(\d+)") { + $actual = [int]$Matches[1] + $desired = [int]$kerberosSettings[$key] + if ($actual -ne $desired) { + Write-Verbose "Kerberos drift: $key = $actual (expected $desired)" + $compliant = $false + } + } else { + Write-Verbose "Kerberos setting not found in local policy: $key" + $compliant = $false + } + } + + return $compliant + } finally { + Remove-Item $tempFile -Force -ErrorAction SilentlyContinue + } + } + } + } +} diff --git a/gpo/default-domain/settings.ps1 b/gpo/default-domain/settings.ps1 new file mode 100644 index 0000000..d893e96 --- /dev/null +++ b/gpo/default-domain/settings.ps1 @@ -0,0 +1,53 @@ +# Default Domain Policy -- Settings Declaration +# GPO GUID: {31B2F340-016D-11D2-945F-00C04FB984F9} (built-in, same on all domains) +# Linked to: example.internal (domain root) +# +# This GPO controls domain-wide password, account lockout, and Kerberos policies. +# These settings ONLY take effect at the domain level -- they are ignored in OU-level GPOs. + +@{ + GPOName = 'Default Domain Policy' + Description = 'Domain-wide password, account lockout, and Kerberos policies' + + DisableUserConfiguration = $true + + # No link management needed -- auto-linked at domain creation + LinkTo = $null + + SecurityPolicy = @{ + + 'System Access' = [ordered]@{ + # --- Password Policy --- + MinimumPasswordAge = 1 # Days before password can be changed + MaximumPasswordAge = 42 # Days before password must be changed + MinimumPasswordLength = 7 # Minimum characters (consider 14+) + PasswordComplexity = 1 # 1=Enabled: requires 3 of 4 char types + PasswordHistorySize = 24 # Previous passwords remembered + ClearTextPassword = 0 # 0=Disabled: no reversible encryption + RequireLogonToChangePassword = 0 # 0=Disabled + ForceLogoffWhenHourExpire = 0 # 0=Disabled: sessions continue after hours expire + LSAAnonymousNameLookup = 0 # 0=Disabled: block anonymous SID-to-name resolution + + # --- Account Lockout Policy --- + LockoutBadCount = 5 # Failed attempts before lockout + ResetLockoutCount = 30 # Minutes before counter resets + LockoutDuration = 30 # Minutes account stays locked + } + + 'Kerberos Policy' = [ordered]@{ + MaxTicketAge = 10 # TGT lifetime in hours + MaxRenewAge = 7 # TGT max renewal in days + MaxServiceAge = 600 # Service ticket lifetime in minutes + MaxClockSkew = 5 # Max clock difference in minutes + TicketValidateClient = 1 # 1=Enabled: validate client identity + } + + 'Registry Values' = [ordered]@{ + # Do not store LAN Manager hash -- LM hashes are trivially crackable + 'MACHINE\System\CurrentControlSet\Control\Lsa\NoLMHash' = '4,1' # REG_DWORD=1 + } + } + + # No registry-based (Administrative Template) settings in this GPO + RegistrySettings = @() +} diff --git a/gpo/lib/GPOAppLocker.ps1 b/gpo/lib/GPOAppLocker.ps1 new file mode 100644 index 0000000..ddf4da7 --- /dev/null +++ b/gpo/lib/GPOAppLocker.ps1 @@ -0,0 +1,272 @@ +# GPOAppLocker.ps1 +# AppLocker policy management via GPO. +# Uses Set-AppLockerPolicy / Get-AppLockerPolicy with -LDAP parameter. +# Depends on: GPOCore.ps1 + +function ConvertTo-AppLockerXml { + <# + .SYNOPSIS + Converts an AppLockerPolicy hashtable into AppLocker XML format. + Generates unique GUIDs per rule and resolves user names to SIDs. + #> + param( + [Parameter(Mandatory)] + [hashtable]$AppLockerPolicy + ) + + $esc = [System.Security.SecurityElement] + + $collectionMap = @{ + Exe = 'Exe' + Msi = 'Msi' + Script = 'Script' + Appx = 'Appx' + Dll = 'Dll' + } + + $collections = foreach ($collectionName in $AppLockerPolicy.Keys) { + $collection = $AppLockerPolicy[$collectionName] + $xmlType = $collectionMap[$collectionName] + if (-not $xmlType) { + Write-Host " [WARN] Unknown AppLocker collection: $collectionName" -ForegroundColor Yellow + continue + } + + $enforcement = if ($collection.EnforcementMode -eq 'Enabled') { + 'Enabled' + } else { + 'AuditOnly' + } + + $ruleXml = foreach ($rule in $collection.Rules) { + $ruleId = [Guid]::NewGuid().ToString() + $ruleName = if ($rule.Name) { $esc::Escape($rule.Name) } else { "$collectionName rule $ruleId" } + $ruleDesc = if ($rule.Description) { $esc::Escape($rule.Description) } else { '' } + $action = $rule.Action # Allow or Deny + + # Resolve user to SID + $userSid = 'S-1-1-0' # Default: Everyone + try { + $ntAccount = New-Object System.Security.Principal.NTAccount($rule.User) + $userSid = $ntAccount.Translate([System.Security.Principal.SecurityIdentifier]).Value + } catch { + # Well-known SIDs + if ($rule.User -eq 'Everyone') { + $userSid = 'S-1-1-0' + } else { + Write-Host " [WARN] Cannot resolve '$($rule.User)' to SID, using Everyone" -ForegroundColor Yellow + } + } + + $conditionXml = switch ($rule.Type) { + 'Publisher' { + $pub = $esc::Escape($rule.Publisher) + $prod = if ($rule.Product) { $esc::Escape($rule.Product) } else { '*' } + $bin = if ($rule.Binary) { $esc::Escape($rule.Binary) } else { '*' } + $lowVer = if ($rule.LowVersion) { $rule.LowVersion } else { '0.0.0.0' } + $highVer = if ($rule.HighVersion) { $rule.HighVersion } else { '*' } + @" + + + + + +"@ + } + 'Path' { + $path = $esc::Escape($rule.Path) + @" + + + +"@ + } + 'Hash' { + $hash = $rule.Hash + $fileName = if ($rule.FileName) { $esc::Escape($rule.FileName) } else { '' } + $fileLength = if ($rule.SourceFileLength) { $rule.SourceFileLength } else { '0' } + @" + + + + + +"@ + } + default { + Write-Host " [WARN] Unknown rule type: $($rule.Type)" -ForegroundColor Yellow + '' + } + } + + $elementName = switch ($rule.Type) { + 'Publisher' { 'FilePublisherRule' } + 'Path' { 'FilePathRule' } + 'Hash' { 'FileHashRule' } + default { 'FilePublisherRule' } + } + + @" + <$elementName Id="$ruleId" Name="$ruleName" Description="$ruleDesc" UserOrGroupSid="$userSid" Action="$action"> +$conditionXml + +"@ + } + + @" + +$($ruleXml -join "`n") + +"@ + } + + return @" + +$($collections -join "`n") + +"@ +} + +function Set-GPOAppLockerPolicy { + <# + .SYNOPSIS + Applies an AppLocker policy to a GPO using Set-AppLockerPolicy. + Full overwrite semantics. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [Parameter(Mandatory)] + [hashtable]$AppLockerPolicy, + + [string]$Domain = (Get-ADDomain).DNSRoot + ) + + Write-Host " Applying AppLocker policy..." -ForegroundColor Yellow + + $xml = ConvertTo-AppLockerXml -AppLockerPolicy $AppLockerPolicy + + # Get GPO GUID for LDAP path + $gpo = Get-GPO -Name $GPOName -Domain $Domain + $gpoGuid = $gpo.Id.ToString('B').ToUpper() + $domainDN = (Get-ADDomain -Server $Domain).DistinguishedName + $ldapPath = "LDAP://CN=$gpoGuid,CN=Policies,CN=System,$domainDN" + + # Write a temp file with the XML (Set-AppLockerPolicy requires -XmlPolicy file path or pipeline) + $tempFile = [System.IO.Path]::GetTempFileName() + try { + [System.IO.File]::WriteAllText($tempFile, $xml, [System.Text.UTF8Encoding]::new($true)) + Set-AppLockerPolicy -XmlPolicy $tempFile -LDAP $ldapPath + Write-Host " [OK] AppLocker policy applied to $GPOName" -ForegroundColor Green + } finally { + Remove-Item $tempFile -Force -ErrorAction SilentlyContinue + } + + # Report what was set + foreach ($coll in $AppLockerPolicy.Keys) { + $ruleCount = @($AppLockerPolicy[$coll].Rules).Count + $mode = $AppLockerPolicy[$coll].EnforcementMode + Write-Host " $coll`: $ruleCount rule(s), $mode" -ForegroundColor Green + } +} + +function Compare-GPOAppLockerPolicy { + <# + .SYNOPSIS + Compares desired AppLocker policy against current GPO state. + Reports missing/extra collections and rules. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [Parameter(Mandatory)] + [hashtable]$AppLockerPolicy, + + [string]$Domain = (Get-ADDomain).DNSRoot + ) + + $diffs = @() + + Write-Host " Comparing AppLocker policy..." -ForegroundColor Yellow + + # Get GPO GUID for LDAP path + $gpo = Get-GPO -Name $GPOName -Domain $Domain + $gpoGuid = $gpo.Id.ToString('B').ToUpper() + $domainDN = (Get-ADDomain -Server $Domain).DistinguishedName + $ldapPath = "LDAP://CN=$gpoGuid,CN=Policies,CN=System,$domainDN" + + # Get current AppLocker policy + $currentXml = $null + try { + $currentXml = Get-AppLockerPolicy -Domain -LDAP $ldapPath -Xml -ErrorAction Stop + } catch { + # No policy set + } + + if (-not $currentXml) { + Write-Host " [DRIFT] No AppLocker policy configured" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'AppLocker' + Collection = '(all)' + Status = 'No policy' + } + return $diffs + } + + # Parse current XML + [xml]$currentDoc = $currentXml + + foreach ($collectionName in $AppLockerPolicy.Keys) { + $desired = $AppLockerPolicy[$collectionName] + $desiredMode = if ($desired.EnforcementMode -eq 'Enabled') { 'Enabled' } else { 'AuditOnly' } + + # Find matching collection in current XML + $currentCollection = $currentDoc.AppLockerPolicy.RuleCollection | + Where-Object { $_.Type -eq $collectionName } + + if (-not $currentCollection) { + Write-Host " [DRIFT] Missing collection: $collectionName" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'AppLocker' + Collection = $collectionName + Status = 'Missing collection' + } + continue + } + + # Check enforcement mode + if ($currentCollection.EnforcementMode -ne $desiredMode) { + Write-Host " [DRIFT] $collectionName enforcement: $($currentCollection.EnforcementMode) -> $desiredMode" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'AppLocker' + Collection = $collectionName + Status = "EnforcementMode: $($currentCollection.EnforcementMode) -> $desiredMode" + } + } + + # Compare rule counts + $currentRuleCount = @($currentCollection.ChildNodes | Where-Object { $_.LocalName -like '*Rule' }).Count + $desiredRuleCount = @($desired.Rules).Count + + if ($currentRuleCount -ne $desiredRuleCount) { + Write-Host " [DRIFT] $collectionName rule count: $currentRuleCount -> $desiredRuleCount" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'AppLocker' + Collection = $collectionName + Status = "Rule count: $currentRuleCount -> $desiredRuleCount" + } + } else { + Write-Host " [OK] $collectionName`: $currentRuleCount rule(s), $($currentCollection.EnforcementMode)" -ForegroundColor Green + } + } + + if ($diffs.Count -eq 0) { + Write-Host " [OK] AppLocker policy matches desired state" -ForegroundColor Green + } else { + Write-Host " [DRIFT] $($diffs.Count) AppLocker difference(s) found" -ForegroundColor Red + } + + return $diffs +} diff --git a/gpo/lib/GPOAudit.ps1 b/gpo/lib/GPOAudit.ps1 new file mode 100644 index 0000000..2a73260 --- /dev/null +++ b/gpo/lib/GPOAudit.ps1 @@ -0,0 +1,217 @@ +# GPOAudit.ps1 +# Advanced Audit Policy (audit.csv) with subcategory GUID lookup table. +# Depends on: GPOCore.ps1 (Get-GPOSysvolPath, Add-GPOExtensionGuids) + +# Well-known subcategory GUIDs -- static across all Windows versions. +$Script:AuditSubcategoryGuid = @{ + # System + 'Security State Change' = '{0CCE9210-69AE-11D9-BED3-505054503030}' + 'Security System Extension' = '{0CCE9211-69AE-11D9-BED3-505054503030}' + 'System Integrity' = '{0CCE9212-69AE-11D9-BED3-505054503030}' + 'IPsec Driver' = '{0CCE9213-69AE-11D9-BED3-505054503030}' + 'Other System Events' = '{0CCE9214-69AE-11D9-BED3-505054503030}' + + # Logon/Logoff + 'Logon' = '{0CCE9215-69AE-11D9-BED3-505054503030}' + 'Logoff' = '{0CCE9216-69AE-11D9-BED3-505054503030}' + 'Account Lockout' = '{0CCE9217-69AE-11D9-BED3-505054503030}' + 'IPsec Main Mode' = '{0CCE9218-69AE-11D9-BED3-505054503030}' + 'IPsec Quick Mode' = '{0CCE9219-69AE-11D9-BED3-505054503030}' + 'IPsec Extended Mode' = '{0CCE921A-69AE-11D9-BED3-505054503030}' + 'Special Logon' = '{0CCE921B-69AE-11D9-BED3-505054503030}' + 'Other Logon/Logoff Events' = '{0CCE921C-69AE-11D9-BED3-505054503030}' + 'Network Policy Server' = '{0CCE9243-69AE-11D9-BED3-505054503030}' + 'User / Device Claims' = '{0CCE9247-69AE-11D9-BED3-505054503030}' + 'Group Membership' = '{0CCE9249-69AE-11D9-BED3-505054503030}' + + # Object Access + 'File System' = '{0CCE921D-69AE-11D9-BED3-505054503030}' + 'Registry' = '{0CCE921E-69AE-11D9-BED3-505054503030}' + 'Kernel Object' = '{0CCE921F-69AE-11D9-BED3-505054503030}' + 'SAM' = '{0CCE9220-69AE-11D9-BED3-505054503030}' + 'Certification Services' = '{0CCE9221-69AE-11D9-BED3-505054503030}' + 'Application Generated' = '{0CCE9222-69AE-11D9-BED3-505054503030}' + 'Handle Manipulation' = '{0CCE9223-69AE-11D9-BED3-505054503030}' + 'File Share' = '{0CCE9224-69AE-11D9-BED3-505054503030}' + 'Filtering Platform Packet Drop' = '{0CCE9225-69AE-11D9-BED3-505054503030}' + 'Filtering Platform Connection' = '{0CCE9226-69AE-11D9-BED3-505054503030}' + 'Other Object Access Events' = '{0CCE9227-69AE-11D9-BED3-505054503030}' + 'Detailed File Share' = '{0CCE9244-69AE-11D9-BED3-505054503030}' + 'Removable Storage' = '{0CCE9245-69AE-11D9-BED3-505054503030}' + 'Central Policy Staging' = '{0CCE9246-69AE-11D9-BED3-505054503030}' + + # Privilege Use + 'Sensitive Privilege Use' = '{0CCE9228-69AE-11D9-BED3-505054503030}' + 'Non Sensitive Privilege Use' = '{0CCE9229-69AE-11D9-BED3-505054503030}' + 'Other Privilege Use Events' = '{0CCE922A-69AE-11D9-BED3-505054503030}' + + # Detailed Tracking + 'Process Creation' = '{0CCE922B-69AE-11D9-BED3-505054503030}' + 'Process Termination' = '{0CCE922C-69AE-11D9-BED3-505054503030}' + 'DPAPI Activity' = '{0CCE922D-69AE-11D9-BED3-505054503030}' + 'RPC Events' = '{0CCE922E-69AE-11D9-BED3-505054503030}' + 'Plug and Play Events' = '{0CCE9248-69AE-11D9-BED3-505054503030}' + + # Policy Change + 'Audit Policy Change' = '{0CCE922F-69AE-11D9-BED3-505054503030}' + 'Authentication Policy Change' = '{0CCE9230-69AE-11D9-BED3-505054503030}' + 'Authorization Policy Change' = '{0CCE9231-69AE-11D9-BED3-505054503030}' + 'MPSSVC Rule-Level Policy Change' = '{0CCE9232-69AE-11D9-BED3-505054503030}' + 'Filtering Platform Policy Change' = '{0CCE9233-69AE-11D9-BED3-505054503030}' + 'Other Policy Change Events' = '{0CCE9234-69AE-11D9-BED3-505054503030}' + + # Account Management + 'User Account Management' = '{0CCE9235-69AE-11D9-BED3-505054503030}' + 'Computer Account Management' = '{0CCE9236-69AE-11D9-BED3-505054503030}' + 'Security Group Management' = '{0CCE9237-69AE-11D9-BED3-505054503030}' + 'Distribution Group Management' = '{0CCE9238-69AE-11D9-BED3-505054503030}' + 'Application Group Management' = '{0CCE9239-69AE-11D9-BED3-505054503030}' + 'Other Account Management Events' = '{0CCE923A-69AE-11D9-BED3-505054503030}' + + # DS Access + 'Directory Service Access' = '{0CCE923B-69AE-11D9-BED3-505054503030}' + 'Directory Service Changes' = '{0CCE923C-69AE-11D9-BED3-505054503030}' + 'Directory Service Replication' = '{0CCE923D-69AE-11D9-BED3-505054503030}' + 'Detailed Directory Service Replication' = '{0CCE923E-69AE-11D9-BED3-505054503030}' + + # Account Logon + 'Credential Validation' = '{0CCE923F-69AE-11D9-BED3-505054503030}' + 'Kerberos Service Ticket Operations' = '{0CCE9240-69AE-11D9-BED3-505054503030}' + 'Other Account Logon Events' = '{0CCE9241-69AE-11D9-BED3-505054503030}' + 'Kerberos Authentication Service' = '{0CCE9242-69AE-11D9-BED3-505054503030}' +} + +# Maps human-readable setting strings to numeric values for audit.csv +$Script:AuditSettingValue = @{ + 'No Auditing' = 0 + 'Success' = 1 + 'Failure' = 2 + 'Success and Failure' = 3 +} + +function Set-GPOAdvancedAuditPolicy { + <# + .SYNOPSIS + Writes an Advanced Audit Policy (audit.csv) to a GPO's SYSVOL path. + This provides subcategory-level audit control (53 subcategories) + instead of the 9 legacy Event Audit categories. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [Parameter(Mandatory)] + [hashtable]$AuditPolicy, + + [string]$Domain = (Get-ADDomain).DNSRoot + ) + + $sysvolPath = Get-GPOSysvolPath -GPOName $GPOName -Domain $Domain + $auditDir = Join-Path $sysvolPath 'Machine\Microsoft\Windows NT\Audit' + $auditCsvPath = Join-Path $auditDir 'audit.csv' + + if (-not (Test-Path $auditDir)) { + New-Item -ItemType Directory -Path $auditDir -Force | Out-Null + } + + # Build CSV content + $sb = [System.Text.StringBuilder]::new() + [void]$sb.AppendLine('Machine Name,Policy Target,Subcategory,Subcategory GUID,Inclusion Setting,Exclusion Setting,Setting Value') + + foreach ($subcategory in $AuditPolicy.Keys) { + $guid = $Script:AuditSubcategoryGuid[$subcategory] + if (-not $guid) { + Write-Host " [WARN] Unknown audit subcategory: '$subcategory'" -ForegroundColor Yellow + continue + } + + $settingName = $AuditPolicy[$subcategory] + $settingValue = $Script:AuditSettingValue[$settingName] + if ($null -eq $settingValue) { + Write-Host " [WARN] Unknown audit setting value: '$settingName' for '$subcategory'" -ForegroundColor Yellow + continue + } + + [void]$sb.AppendLine(",System,$subcategory,$guid,$settingName,,$settingValue") + } + + # audit.csv uses UTF-8 with BOM + $utf8Bom = [System.Text.UTF8Encoding]::new($true) + [System.IO.File]::WriteAllText($auditCsvPath, $sb.ToString(), $utf8Bom) + Write-Host " Written: $auditCsvPath" -ForegroundColor Green + Write-Host " $($AuditPolicy.Count) audit subcategory setting(s) configured." -ForegroundColor Green + + # Register Audit Policy CSE GUID + $auditCseGuid = '{F3BC9527-C350-4C90-861C-1EC90034520B}' + $auditToolGuid = '{D02B1F72-3407-48AE-BA88-E8213C6761F1}' + Add-GPOExtensionGuids -GPOName $GPOName -CseGuid $auditCseGuid -ToolGuid $auditToolGuid -Scope Machine -Domain $Domain + +} + +function Compare-GPOAdvancedAuditPolicy { + <# + .SYNOPSIS + Compares desired Advanced Audit Policy settings against the current + audit.csv in SYSVOL. Returns diff objects for mismatches. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [Parameter(Mandatory)] + [hashtable]$AuditPolicy, + + [string]$Domain = (Get-ADDomain).DNSRoot + ) + + $sysvolPath = Get-GPOSysvolPath -GPOName $GPOName -Domain $Domain + $auditCsvPath = Join-Path $sysvolPath 'Machine\Microsoft\Windows NT\Audit\audit.csv' + + $diffs = @() + + Write-Host " Comparing advanced audit policy..." -ForegroundColor Yellow + + # Parse existing audit.csv into a lookup + $currentSettings = @{} + if (Test-Path $auditCsvPath) { + $csvLines = [System.IO.File]::ReadAllLines($auditCsvPath, [System.Text.UTF8Encoding]::new($true)) + foreach ($line in $csvLines) { + # Skip header and empty lines + if ($line -match '^Machine Name,' -or [string]::IsNullOrWhiteSpace($line)) { continue } + + $fields = $line -split ',' + if ($fields.Count -ge 5) { + $subcategory = $fields[2].Trim() + $inclusionSetting = $fields[4].Trim() + $currentSettings[$subcategory] = $inclusionSetting + } + } + } + + foreach ($subcategory in $AuditPolicy.Keys) { + $guid = $Script:AuditSubcategoryGuid[$subcategory] + if (-not $guid) { continue } + + $desired = $AuditPolicy[$subcategory] + $current = $currentSettings[$subcategory] + + if ($current -ne $desired) { + $currentDisplay = if ($null -eq $current) { '(not set)' } else { $current } + Write-Host " [DRIFT] $subcategory`: '$currentDisplay' -> '$desired'" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'AdvancedAudit' + Subcategory = $subcategory + Current = $currentDisplay + Desired = $desired + } + } + } + + if ($diffs.Count -eq 0) { + Write-Host " [OK] Advanced audit policy matches desired state" -ForegroundColor Green + } else { + Write-Host " [DRIFT] $($diffs.Count) audit policy difference(s) found" -ForegroundColor Red + } + + return $diffs +} diff --git a/gpo/lib/GPOBackup.ps1 b/gpo/lib/GPOBackup.ps1 new file mode 100644 index 0000000..591c18d --- /dev/null +++ b/gpo/lib/GPOBackup.ps1 @@ -0,0 +1,214 @@ +# GPOBackup.ps1 +# GPO state backup and restore functions. +# Depends on: GPOCore.ps1 (Get-GPOSysvolPath, Update-GPOVersion) + +$Script:DefaultBackupRoot = Join-Path $PSScriptRoot '..\backups' +$Script:DefaultRetention = 5 + +function Backup-GPOState { + <# + .SYNOPSIS + Snapshots a GPO's SYSVOL directory tree and AD attributes to a + timestamped backup directory. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [string]$BackupRoot = $Script:DefaultBackupRoot, + + [string]$Domain = (Get-ADDomain).DNSRoot + ) + + $gpo = Get-GPO -Name $GPOName -Domain $Domain -ErrorAction Stop + $gpoGuid = "{$($gpo.Id)}" + $domainDN = (Get-ADDomain -Server $Domain).DistinguishedName + $gpoDN = "CN=$gpoGuid,CN=Policies,CN=System,$domainDN" + $sysvolPath = Get-GPOSysvolPath -GPOName $GPOName -Domain $Domain + + # Create timestamped backup directory + $timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $safeName = $GPOName -replace '[^\w\-]', '_' + $backupDir = Join-Path $BackupRoot "$safeName\$timestamp" + + if (-not (Test-Path $backupDir)) { + New-Item -ItemType Directory -Path $backupDir -Force | Out-Null + } + + # --- Snapshot SYSVOL --- + # robocopy handles empty directories and UNC paths reliably; + # Copy-Item -Recurse fails on empty dirs in PowerShell 5.1. + $sysvolBackup = Join-Path $backupDir 'sysvol' + if (Test-Path $sysvolPath) { + $null = robocopy $sysvolPath $sysvolBackup /E /R:0 /W:0 /NJH /NJS /NFL /NDL /NP + Write-Host " [BACKUP] SYSVOL files captured" -ForegroundColor Cyan + } else { + New-Item -ItemType Directory -Path $sysvolBackup -Force | Out-Null + Write-Host " [BACKUP] No SYSVOL content to back up" -ForegroundColor DarkGray + } + + # --- Snapshot AD attributes --- + $adObj = Get-ADObject $gpoDN -Properties ` + versionNumber, description, gPCMachineExtensionNames, ` + gPCUserExtensionNames, gPCWQLFilter + + $metadata = @{ + GPOName = $GPOName + GPOGuid = $gpoGuid + Timestamp = (Get-Date).ToString('o') + Admin = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name + Domain = $Domain + VersionNumber = $adObj.versionNumber + Description = $adObj.description + gPCMachineExtensionNames = $adObj.gPCMachineExtensionNames + gPCUserExtensionNames = $adObj.gPCUserExtensionNames + gPCWQLFilter = $adObj.gPCWQLFilter + } + + $metadataPath = Join-Path $backupDir 'metadata.json' + $metadata | ConvertTo-Json -Depth 4 | Set-Content -Path $metadataPath -Encoding UTF8 + Write-Host " [BACKUP] AD metadata captured -> $timestamp" -ForegroundColor Cyan + + # --- Enforce retention --- + Invoke-GPOBackupRetention -GPOName $GPOName -BackupRoot $BackupRoot + + return $backupDir +} + +function Invoke-GPOBackupRetention { + <# + .SYNOPSIS + Removes old backups beyond the retention limit (default 5). + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [string]$BackupRoot = $Script:DefaultBackupRoot, + + [int]$RetentionCount = $Script:DefaultRetention + ) + + $safeName = $GPOName -replace '[^\w\-]', '_' + $gpoBackupDir = Join-Path $BackupRoot $safeName + + if (-not (Test-Path $gpoBackupDir)) { return } + + $backups = Get-ChildItem -Path $gpoBackupDir -Directory | Sort-Object Name -Descending + if ($backups.Count -gt $RetentionCount) { + $toRemove = $backups | Select-Object -Skip $RetentionCount + foreach ($old in $toRemove) { + Remove-Item -Path $old.FullName -Recurse -Force + Write-Host " [BACKUP] Pruned old backup: $($old.Name)" -ForegroundColor DarkGray + } + } +} + +function Get-GPOBackups { + <# + .SYNOPSIS + Lists available backups for a GPO (or all GPOs if no name specified). + #> + param( + [string]$GPOName, + + [string]$BackupRoot = $Script:DefaultBackupRoot + ) + + if (-not (Test-Path $BackupRoot)) { + return @() + } + + $results = @() + + if ($GPOName) { + $safeName = $GPOName -replace '[^\w\-]', '_' + $searchPath = Join-Path $BackupRoot $safeName + if (-not (Test-Path $searchPath)) { return @() } + $gpoDirs = @(Get-Item $searchPath) + } else { + $gpoDirs = Get-ChildItem -Path $BackupRoot -Directory + } + + foreach ($gpoDir in $gpoDirs) { + $backups = Get-ChildItem -Path $gpoDir.FullName -Directory | Sort-Object Name -Descending + foreach ($backup in $backups) { + $metadataPath = Join-Path $backup.FullName 'metadata.json' + if (Test-Path $metadataPath) { + $meta = Get-Content $metadataPath -Raw | ConvertFrom-Json + $results += [PSCustomObject]@{ + GPOName = $meta.GPOName + Timestamp = $backup.Name + Admin = $meta.Admin + Version = $meta.VersionNumber + Path = $backup.FullName + } + } + } + } + + return $results +} + +function Restore-GPOState { + <# + .SYNOPSIS + Restores a GPO's SYSVOL files and AD attributes from a backup. + .DESCRIPTION + Copies backed-up SYSVOL content back to the live SYSVOL path and + restores AD attributes (version, extensions, description, WMI filter link). + Bumps the version number to force clients to reprocess. + #> + param( + [Parameter(Mandatory)] + [string]$BackupPath, + + [string]$Domain = (Get-ADDomain).DNSRoot + ) + + $metadataPath = Join-Path $BackupPath 'metadata.json' + if (-not (Test-Path $metadataPath)) { + throw "Backup metadata not found at $BackupPath" + } + + $meta = Get-Content $metadataPath -Raw | ConvertFrom-Json + + $gpoName = $meta.GPOName + $gpo = Get-GPO -Name $gpoName -Domain $Domain -ErrorAction Stop + $gpoGuid = "{$($gpo.Id)}" + $domainDN = (Get-ADDomain -Server $Domain).DistinguishedName + $gpoDN = "CN=$gpoGuid,CN=Policies,CN=System,$domainDN" + $sysvolPath = Get-GPOSysvolPath -GPOName $gpoName -Domain $Domain + + Write-Host "Restoring GPO: $gpoName from backup $($meta.Timestamp)" -ForegroundColor Cyan + + # --- Restore SYSVOL --- + $sysvolBackup = Join-Path $BackupPath 'sysvol' + if (Test-Path $sysvolBackup) { + $backedUpContent = Get-ChildItem $sysvolBackup + if ($backedUpContent.Count -gt 0) { + $null = robocopy $sysvolBackup $sysvolPath /E /R:0 /W:0 /NJH /NJS /NFL /NDL /NP + Write-Host " [RESTORED] SYSVOL files" -ForegroundColor Green + } else { + Write-Host " [RESTORED] No SYSVOL content in backup (empty)" -ForegroundColor DarkGray + } + } + + # --- Restore AD attributes --- + $replaceAttrs = @{} + if ($meta.Description) { $replaceAttrs['description'] = $meta.Description } + if ($meta.gPCMachineExtensionNames) { $replaceAttrs['gPCMachineExtensionNames'] = $meta.gPCMachineExtensionNames } + if ($meta.gPCUserExtensionNames) { $replaceAttrs['gPCUserExtensionNames'] = $meta.gPCUserExtensionNames } + if ($meta.gPCWQLFilter) { $replaceAttrs['gPCWQLFilter'] = $meta.gPCWQLFilter } + + if ($replaceAttrs.Count -gt 0) { + Set-ADObject $gpoDN -Replace $replaceAttrs + Write-Host " [RESTORED] AD attributes" -ForegroundColor Green + } + + # --- Bump version to force client reprocessing --- + Update-GPOVersion -GPOName $gpoName -Scope Both -Domain $Domain + Write-Host " [RESTORED] Version bumped (clients will reprocess)" -ForegroundColor Green + + Write-Host "Restore complete for $gpoName." -ForegroundColor Green +} diff --git a/gpo/lib/GPOCore.ps1 b/gpo/lib/GPOCore.ps1 new file mode 100644 index 0000000..faedfb1 --- /dev/null +++ b/gpo/lib/GPOCore.ps1 @@ -0,0 +1,273 @@ +# GPOCore.ps1 +# Shared utility functions used by multiple GPO modules. +# Must be loaded before other GPO modules (they depend on these functions). + +function Get-GPOSysvolPath { + <# + .SYNOPSIS + Resolves a GPO name to its SYSVOL filesystem path. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [string]$Domain = (Get-ADDomain).DNSRoot + ) + + $gpo = Get-GPO -Name $GPOName -ErrorAction Stop + $guid = "{$($gpo.Id)}" + return "\\$Domain\SYSVOL\$Domain\Policies\$guid" +} + +function Get-GPOSecurityTemplatePath { + <# + .SYNOPSIS + Returns the full path to a GPO's GptTmpl.inf file. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [string]$Domain = (Get-ADDomain).DNSRoot + ) + + $sysvolPath = Get-GPOSysvolPath -GPOName $GPOName -Domain $Domain + return Join-Path $sysvolPath 'MACHINE\Microsoft\Windows NT\SecEdit\GptTmpl.inf' +} + +function Update-GPOVersion { + <# + .SYNOPSIS + Bumps a GPO's version number in both AD and GPT.INI. + .DESCRIPTION + The GPO version number is a packed 32-bit integer: + - Upper 16 bits = user configuration version + - Lower 16 bits = machine configuration version + This function increments only the relevant half based on -Scope. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [ValidateSet('Machine', 'User', 'Both')] + [string]$Scope = 'Machine', + + [string]$Domain = (Get-ADDomain).DNSRoot + ) + + $gpo = Get-GPO -Name $GPOName -Domain $Domain + $gpoGuid = "{$($gpo.Id)}" + $gpoDN = "CN=$gpoGuid,CN=Policies,CN=System,$((Get-ADDomain -Server $Domain).DistinguishedName)" + + # Read current packed version and split into halves + $adVersion = [int](Get-ADObject $gpoDN -Properties versionNumber).versionNumber + $machineVer = $adVersion -band 0xFFFF + $userVer = ($adVersion -shr 16) -band 0xFFFF + + switch ($Scope) { + 'Machine' { $machineVer++ } + 'User' { $userVer++ } + 'Both' { $machineVer++; $userVer++ } + } + + $newVersion = ($userVer -shl 16) -bor $machineVer + Set-ADObject $gpoDN -Replace @{ versionNumber = $newVersion } + + # GPT.INI uses the same packed format + $gptIniPath = Join-Path (Get-GPOSysvolPath -GPOName $GPOName -Domain $Domain) 'GPT.INI' + if (Test-Path $gptIniPath) { + $gptContent = Get-Content $gptIniPath -Raw + if ($gptContent -match 'Version=(\d+)') { + $oldVer = [int]$Matches[1] + $oldMachine = $oldVer -band 0xFFFF + $oldUser = ($oldVer -shr 16) -band 0xFFFF + + switch ($Scope) { + 'Machine' { $oldMachine++ } + 'User' { $oldUser++ } + 'Both' { $oldMachine++; $oldUser++ } + } + + $newGptVer = ($oldUser -shl 16) -bor $oldMachine + $gptContent = $gptContent -replace "Version=$oldVer", "Version=$newGptVer" + Set-Content -Path $gptIniPath -Value $gptContent -NoNewline + } + } +} + +function Add-GPOExtensionGuids { + <# + .SYNOPSIS + Ensures the specified CSE/tool GUID pair is present in a GPO's + extension name attributes (gPCMachineExtensionNames or gPCUserExtensionNames). + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [Parameter(Mandatory)] + [string]$CseGuid, + + [Parameter(Mandatory)] + [string]$ToolGuid, + + [ValidateSet('Machine', 'User')] + [string]$Scope = 'Machine', + + [string]$Domain = (Get-ADDomain).DNSRoot + ) + + $gpo = Get-GPO -Name $GPOName -Domain $Domain + $gpoGuid = "{$($gpo.Id)}" + $gpoDN = "CN=$gpoGuid,CN=Policies,CN=System,$((Get-ADDomain -Server $Domain).DistinguishedName)" + + $attrName = if ($Scope -eq 'Machine') { 'gPCMachineExtensionNames' } else { 'gPCUserExtensionNames' } + $obj = Get-ADObject $gpoDN -Properties $attrName + $current = $obj.$attrName + if (-not $current) { $current = '' } + + $pair = "[$CseGuid$ToolGuid]" + if ($current.Contains($pair)) { return } + + # Parse existing pairs, add new one, sort by CSE GUID + $pairs = [System.Collections.Generic.List[string]]::new() + $regex = [regex]'\[\{[0-9A-Fa-f-]+\}\{[0-9A-Fa-f-]+\}\]' + foreach ($m in $regex.Matches($current)) { + $pairs.Add($m.Value) + } + $pairs.Add($pair) + $sorted = $pairs | Sort-Object + + $newValue = -join $sorted + Set-ADObject $gpoDN -Replace @{ $attrName = $newValue } +} + +function Compare-GPOStatus { + <# + .SYNOPSIS + Compares the desired GPO status (enabled/disabled sections) against + the current state. Returns a diff object if they differ. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [bool]$DisableUserConfiguration = $false, + [bool]$DisableComputerConfiguration = $false + ) + + $gpo = Get-GPO -Name $GPOName -ErrorAction Stop + + $desiredStatus = 'AllSettingsEnabled' + if ($DisableUserConfiguration -and $DisableComputerConfiguration) { + $desiredStatus = 'AllSettingsDisabled' + } elseif ($DisableUserConfiguration) { + $desiredStatus = 'UserSettingsDisabled' + } elseif ($DisableComputerConfiguration) { + $desiredStatus = 'ComputerSettingsDisabled' + } + + $currentStatus = $gpo.GpoStatus.ToString() + + if ($currentStatus -ne $desiredStatus) { + Write-Host " [DRIFT] GpoStatus: '$currentStatus' -> '$desiredStatus'" -ForegroundColor Red + return [PSCustomObject]@{ + Type = 'GpoStatus' + GPO = $GPOName + Current = $currentStatus + Desired = $desiredStatus + } + } + Write-Host " [OK] GpoStatus: $currentStatus" -ForegroundColor Green + return $null +} + +function Ensure-GPOStatus { + <# + .SYNOPSIS + Sets the GPO status (enabled/disabled Computer/User configuration). + Uses the GPO object's GpoStatus property which sets gPCOptions internally. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [bool]$DisableUserConfiguration = $false, + [bool]$DisableComputerConfiguration = $false + ) + + $gpo = Get-GPO -Name $GPOName -ErrorAction Stop + + $desiredStatus = 'AllSettingsEnabled' + if ($DisableUserConfiguration -and $DisableComputerConfiguration) { + $desiredStatus = 'AllSettingsDisabled' + } elseif ($DisableUserConfiguration) { + $desiredStatus = 'UserSettingsDisabled' + } elseif ($DisableComputerConfiguration) { + $desiredStatus = 'ComputerSettingsDisabled' + } + + $currentStatus = $gpo.GpoStatus.ToString() + if ($currentStatus -ne $desiredStatus) { + $gpo.GpoStatus = [Microsoft.GroupPolicy.GpoStatus]::$desiredStatus + Write-Host " [UPDATED] GpoStatus: $currentStatus -> $desiredStatus" -ForegroundColor Yellow + } +} + +# ------------------------------------------------------------------- +# DSC Helper Functions +# ------------------------------------------------------------------- + +function Resolve-SIDsToNames { + <# + .SYNOPSIS + Translates a GptTmpl.inf SID string into an array of NTAccount names. + .EXAMPLE + Resolve-SIDsToNames '*S-1-5-32-544,*S-1-5-20' + # Returns: @('BUILTIN\Administrators', 'NT AUTHORITY\NETWORK SERVICE') + #> + param( + [Parameter(Mandatory)] + [string]$SIDString + ) + + $sids = $SIDString -split ',' | ForEach-Object { $_.Trim().TrimStart('*') } + $names = @() + + foreach ($sidStr in $sids) { + try { + $sid = New-Object System.Security.Principal.SecurityIdentifier($sidStr) + $account = $sid.Translate([System.Security.Principal.NTAccount]) + $names += $account.Value + } catch { + # If translation fails (e.g., virtual service account), keep the raw SID + Write-Warning "Could not resolve SID '$sidStr' to a name. Using raw SID." + $names += $sidStr + } + } + + return $names +} + +function Get-GptTmplRegValue { + <# + .SYNOPSIS + Parses a GptTmpl.inf Registry Values entry and returns the numeric value. + .DESCRIPTION + GptTmpl.inf stores registry values as 'type,value' (e.g., '4,1' = REG_DWORD 1). + This function strips the type prefix and returns the value as an integer. + .EXAMPLE + Get-GptTmplRegValue '4,1' # Returns: 1 + #> + param( + [Parameter(Mandatory)] + [string]$RegValueString + ) + + $parts = $RegValueString -split ',', 2 + if ($parts.Count -lt 2) { + throw "Invalid GptTmpl.inf registry value format: '$RegValueString'. Expected 'type,value'." + } + + return [int]$parts[1] +} diff --git a/gpo/lib/GPOFirewall.ps1 b/gpo/lib/GPOFirewall.ps1 new file mode 100644 index 0000000..7b47f80 --- /dev/null +++ b/gpo/lib/GPOFirewall.ps1 @@ -0,0 +1,303 @@ +# GPOFirewall.ps1 +# Windows Firewall rule and profile management via GPO. +# Uses Open-NetGPO / New-NetFirewallRule -GPOSession / Save-NetGPO cmdlets. +# Depends on: GPOCore.ps1 (Get-GPOSysvolPath) + +function Set-GPOFirewallProfiles { + <# + .SYNOPSIS + Sets Windows Firewall profile settings (Domain/Private/Public) on a GPO + via registry values. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [Parameter(Mandatory)] + [hashtable]$FirewallProfiles, + + [string]$Domain = (Get-ADDomain).DNSRoot + ) + + $profileKeyMap = @{ + Domain = 'HKLM\Software\Policies\Microsoft\WindowsFirewall\DomainProfile' + Private = 'HKLM\Software\Policies\Microsoft\WindowsFirewall\PrivateProfile' + Public = 'HKLM\Software\Policies\Microsoft\WindowsFirewall\PublicProfile' + } + + $actionMap = @{ + 'Block' = 1 + 'Allow' = 0 + 'NotConfigured' = 0 + } + + foreach ($profileName in $FirewallProfiles.Keys) { + $profile = $FirewallProfiles[$profileName] + $regKey = $profileKeyMap[$profileName] + if (-not $regKey) { + Write-Host " [WARN] Unknown firewall profile: $profileName" -ForegroundColor Yellow + continue + } + + # Enable/Disable firewall for this profile + if ($profile.ContainsKey('Enabled')) { + $enableValue = if ($profile.Enabled) { 1 } else { 0 } + Set-GPRegistryValue -Name $GPOName -Key $regKey -ValueName 'EnableFirewall' ` + -Type DWord -Value $enableValue -Domain $Domain | Out-Null + } + + # Default inbound action + if ($profile.ContainsKey('DefaultInboundAction')) { + $inValue = $actionMap[$profile.DefaultInboundAction] + Set-GPRegistryValue -Name $GPOName -Key $regKey -ValueName 'DefaultInboundAction' ` + -Type DWord -Value $inValue -Domain $Domain | Out-Null + } + + # Default outbound action + if ($profile.ContainsKey('DefaultOutboundAction')) { + $outValue = $actionMap[$profile.DefaultOutboundAction] + Set-GPRegistryValue -Name $GPOName -Key $regKey -ValueName 'DefaultOutboundAction' ` + -Type DWord -Value $outValue -Domain $Domain | Out-Null + } + + Write-Host " [SET] Firewall profile: $profileName" -ForegroundColor Green + } +} + +function Compare-GPOFirewallProfiles { + <# + .SYNOPSIS + Compares desired firewall profile settings against current GPO state. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [Parameter(Mandatory)] + [hashtable]$FirewallProfiles, + + [string]$Domain = (Get-ADDomain).DNSRoot + ) + + $profileKeyMap = @{ + Domain = 'HKLM\Software\Policies\Microsoft\WindowsFirewall\DomainProfile' + Private = 'HKLM\Software\Policies\Microsoft\WindowsFirewall\PrivateProfile' + Public = 'HKLM\Software\Policies\Microsoft\WindowsFirewall\PublicProfile' + } + + $actionMap = @{ + 'Block' = 1 + 'Allow' = 0 + 'NotConfigured' = 0 + } + + $diffs = @() + + Write-Host " Comparing firewall profiles..." -ForegroundColor Yellow + + foreach ($profileName in $FirewallProfiles.Keys) { + $profile = $FirewallProfiles[$profileName] + $regKey = $profileKeyMap[$profileName] + if (-not $regKey) { continue } + + $settingsToCheck = @() + + if ($profile.ContainsKey('Enabled')) { + $desired = if ($profile.Enabled) { 1 } else { 0 } + $settingsToCheck += @{ ValueName = 'EnableFirewall'; Desired = $desired } + } + if ($profile.ContainsKey('DefaultInboundAction')) { + $settingsToCheck += @{ ValueName = 'DefaultInboundAction'; Desired = $actionMap[$profile.DefaultInboundAction] } + } + if ($profile.ContainsKey('DefaultOutboundAction')) { + $settingsToCheck += @{ ValueName = 'DefaultOutboundAction'; Desired = $actionMap[$profile.DefaultOutboundAction] } + } + + foreach ($setting in $settingsToCheck) { + $current = $null + try { + $regResult = Get-GPRegistryValue -Name $GPOName -Key $regKey -ValueName $setting.ValueName ` + -Domain $Domain -ErrorAction Stop + $current = $regResult.Value + } catch { + $current = $null + } + + if ($current -ne $setting.Desired) { + $currentDisplay = if ($null -eq $current) { '(not set)' } else { $current } + Write-Host " [DRIFT] $profileName\$($setting.ValueName): $currentDisplay -> $($setting.Desired)" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'FirewallProfile' + Profile = $profileName + Setting = $setting.ValueName + Current = $currentDisplay + Desired = $setting.Desired + } + } else { + Write-Host " [OK] $profileName\$($setting.ValueName) = $current" -ForegroundColor Green + } + } + } + + if ($diffs.Count -eq 0) { + Write-Host " [OK] All firewall profiles match desired state" -ForegroundColor Green + } + + return $diffs +} + +function Set-GPOFirewall { + <# + .SYNOPSIS + Writes Windows Firewall rules to a GPO using Open-NetGPO session. + Full overwrite semantics -- removes all existing rules, then creates declared rules. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [Parameter(Mandatory)] + [array]$FirewallRules, + + [string]$Domain = (Get-ADDomain).DNSRoot + ) + + $domainFqdn = $Domain + Write-Host " Applying firewall rules..." -ForegroundColor Yellow + + # Open a GPO session + $gpoSession = Open-NetGPO -PolicyStore "$domainFqdn\$GPOName" + + # Remove all existing rules in this GPO + try { + $existing = Get-NetFirewallRule -GPOSession $gpoSession -ErrorAction SilentlyContinue + if ($existing) { + Remove-NetFirewallRule -GPOSession $gpoSession -All + Write-Host " Removed $(@($existing).Count) existing rule(s)" -ForegroundColor Yellow + } + } catch { + # No existing rules -- nothing to remove + } + + # Create each declared rule + foreach ($rule in $FirewallRules) { + $params = @{ + GPOSession = $gpoSession + DisplayName = $rule.DisplayName + Direction = $rule.Direction + Action = $rule.Action + } + + if ($rule.Protocol) { $params.Protocol = $rule.Protocol } + if ($rule.LocalPort) { $params.LocalPort = $rule.LocalPort } + if ($rule.RemotePort) { $params.RemotePort = $rule.RemotePort } + if ($rule.LocalAddress) { $params.LocalAddress = $rule.LocalAddress } + if ($rule.RemoteAddress) { $params.RemoteAddress = $rule.RemoteAddress } + if ($rule.Program) { $params.Program = $rule.Program } + if ($rule.Description) { $params.Description = $rule.Description } + + if ($rule.ContainsKey('Enabled')) { + $params.Enabled = if ($rule.Enabled) { 'True' } else { 'False' } + } + + if ($rule.Profile) { + $params.Profile = $rule.Profile + } + + New-NetFirewallRule @params | Out-Null + Write-Host " [CREATED] $($rule.DisplayName)" -ForegroundColor Green + } + + # Save the GPO session + Save-NetGPO -GPOSession $gpoSession + Write-Host " [OK] $($FirewallRules.Count) firewall rule(s) applied" -ForegroundColor Green +} + +function Compare-GPOFirewall { + <# + .SYNOPSIS + Compares desired firewall rules against current GPO state. + Reports missing, extra, and differing rules. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [Parameter(Mandatory)] + [array]$FirewallRules, + + [string]$Domain = (Get-ADDomain).DNSRoot + ) + + $domainFqdn = $Domain + $diffs = @() + + Write-Host " Comparing firewall rules..." -ForegroundColor Yellow + + # Read rules via PolicyStore (Get-NetFirewallRule has no -GPOSession parameter) + $policyStore = "$domainFqdn\$GPOName" + + $existing = @() + try { + $existing = @(Get-NetFirewallRule -PolicyStore $policyStore -ErrorAction SilentlyContinue) + } catch { + # No rules found + } + + $existingByName = @{} + foreach ($r in $existing) { + $existingByName[$r.DisplayName] = $r + } + + # Check for missing rules + foreach ($rule in $FirewallRules) { + if ($existingByName.ContainsKey($rule.DisplayName)) { + $current = $existingByName[$rule.DisplayName] + $mismatch = $false + + if ($rule.Direction -and $current.Direction -ne $rule.Direction) { $mismatch = $true } + if ($rule.Action -and $current.Action -ne $rule.Action) { $mismatch = $true } + + if ($mismatch) { + Write-Host " [DRIFT] Rule differs: $($rule.DisplayName)" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'FirewallRule' + Rule = $rule.DisplayName + Status = 'Differs' + } + } else { + Write-Host " [OK] $($rule.DisplayName)" -ForegroundColor Green + } + } else { + Write-Host " [DRIFT] Missing rule: $($rule.DisplayName)" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'FirewallRule' + Rule = $rule.DisplayName + Status = 'Missing' + } + } + } + + # Check for extra rules (in GPO but not declared) + $declaredNames = @{} + foreach ($rule in $FirewallRules) { $declaredNames[$rule.DisplayName] = $true } + + foreach ($r in $existing) { + if (-not $declaredNames.ContainsKey($r.DisplayName)) { + Write-Host " [DRIFT] Extra rule: $($r.DisplayName)" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'FirewallRule' + Rule = $r.DisplayName + Status = 'Extra (undeclared)' + } + } + } + + if ($diffs.Count -eq 0) { + Write-Host " [OK] All firewall rules match desired state" -ForegroundColor Green + } else { + Write-Host " [DRIFT] $($diffs.Count) firewall rule difference(s) found" -ForegroundColor Red + } + + return $diffs +} diff --git a/gpo/lib/GPOFolderRedirection.ps1 b/gpo/lib/GPOFolderRedirection.ps1 new file mode 100644 index 0000000..a489617 --- /dev/null +++ b/gpo/lib/GPOFolderRedirection.ps1 @@ -0,0 +1,215 @@ +# GPOFolderRedirection.ps1 +# Folder Redirection management via GPO. +# Writes fdeploy1.ini to SYSVOL and registers the CSE extension GUIDs. +# Depends on: GPOCore.ps1 (Get-GPOSysvolPath, Add-GPOExtensionGuids) + +$Script:FolderRedirectionCseGuid = '{25537BA6-77A8-11D2-9B6C-0000F8080861}' +$Script:FolderRedirectionToolGuid = '{88E729D6-BDC1-11D1-BD2A-00C04FB9603F}' + +# Maps settings.ps1 key names to fdeploy1.ini section names +$Script:FolderSectionMap = @{ + Desktop = '.Desktop' + Documents = '.Documents' + Pictures = '.Pictures' + Music = '.Music' + Videos = '.Videos' + Favorites = '.Favorites' + AppDataRoaming = '.AppData' + Contacts = '.Contacts' + Downloads = '.Downloads' + Links = '.Links' + Searches = '.Searches' + StartMenu = '.StartMenu' +} + +function ConvertTo-FolderRedirectionIni { + <# + .SYNOPSIS + Converts a FolderRedirection hashtable into fdeploy1.ini content. + #> + param( + [Parameter(Mandatory)] + [hashtable]$FolderRedirection + ) + + $sections = foreach ($folderName in $FolderRedirection.Keys) { + $folder = $FolderRedirection[$folderName] + $sectionName = $Script:FolderSectionMap[$folderName] + if (-not $sectionName) { + Write-Host " [WARN] Unknown folder: $folderName" -ForegroundColor Yellow + continue + } + + $destPath = $folder.Path + $grantExclusive = if ($folder.GrantExclusive) { '0x00000001' } else { '0x00000000' } + $moveContents = if ($folder.MoveContents) { '0x00000001' } else { '0x00000000' } + + # Status=0x0001 = basic redirection (follow this folder) + @" +[$sectionName] +Status=0x0001 +DestPath=$destPath +GrantExclusive=$grantExclusive +MoveContents=$moveContents +"@ + } + + return ($sections -join "`r`n`r`n") + "`r`n" +} + +function Set-GPOFolderRedirection { + <# + .SYNOPSIS + Writes fdeploy1.ini to SYSVOL for folder redirection. + Full overwrite semantics. User scope only. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [Parameter(Mandatory)] + [hashtable]$FolderRedirection, + + [string]$Domain = (Get-ADDomain).DNSRoot + ) + + Write-Host " Applying folder redirection..." -ForegroundColor Yellow + + $ini = ConvertTo-FolderRedirectionIni -FolderRedirection $FolderRedirection + + $sysvolPath = Get-GPOSysvolPath -GPOName $GPOName -Domain $Domain + $deployDir = Join-Path $sysvolPath 'User\Documents & Settings' + + if (-not (Test-Path $deployDir)) { + New-Item -ItemType Directory -Path $deployDir -Force | Out-Null + } + + $iniPath = Join-Path $deployDir 'fdeploy1.ini' + + # Write as UTF-16LE (same encoding as other INI files in SYSVOL) + $utf16le = [System.Text.UnicodeEncoding]::new($false, $true) + [System.IO.File]::WriteAllText($iniPath, $ini, $utf16le) + + Write-Host " Written: User\Documents & Settings\fdeploy1.ini" -ForegroundColor Green + + # Register CSE extension GUIDs + Add-GPOExtensionGuids -GPOName $GPOName ` + -CseGuid $Script:FolderRedirectionCseGuid ` + -ToolGuid $Script:FolderRedirectionToolGuid ` + -Scope User -Domain $Domain + + # Report what was configured + foreach ($folderName in $FolderRedirection.Keys) { + Write-Host " $folderName -> $($FolderRedirection[$folderName].Path)" -ForegroundColor Green + } +} + +function Compare-GPOFolderRedirection { + <# + .SYNOPSIS + Compares desired folder redirection against current fdeploy1.ini in SYSVOL. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [Parameter(Mandatory)] + [hashtable]$FolderRedirection, + + [string]$Domain = (Get-ADDomain).DNSRoot + ) + + $diffs = @() + + Write-Host " Comparing folder redirection..." -ForegroundColor Yellow + + $sysvolPath = Get-GPOSysvolPath -GPOName $GPOName -Domain $Domain + $iniPath = Join-Path $sysvolPath 'User\Documents & Settings\fdeploy1.ini' + + if (-not (Test-Path $iniPath)) { + Write-Host " [DRIFT] Missing: fdeploy1.ini" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'FolderRedirection' + Folder = '(all)' + Status = 'File missing' + } + return $diffs + } + + # Parse existing INI + $currentContent = Get-Content $iniPath -Raw + $currentSections = @{} + $currentSection = $null + + foreach ($line in ($currentContent -split "`r?`n")) { + $trimmed = $line.Trim() + if ($trimmed -match '^\[(.+)\]$') { + $currentSection = $Matches[1] + $currentSections[$currentSection] = @{} + } elseif ($currentSection -and $trimmed -match '^(.+?)=(.*)$') { + $currentSections[$currentSection][$Matches[1]] = $Matches[2] + } + } + + foreach ($folderName in $FolderRedirection.Keys) { + $desired = $FolderRedirection[$folderName] + $sectionName = $Script:FolderSectionMap[$folderName] + if (-not $sectionName) { continue } + + if (-not $currentSections.ContainsKey($sectionName)) { + Write-Host " [DRIFT] Missing section: $sectionName ($folderName)" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'FolderRedirection' + Folder = $folderName + Status = 'Missing section' + } + continue + } + + $section = $currentSections[$sectionName] + + # Check DestPath + $currentPath = $section['DestPath'] + if ($currentPath -ne $desired.Path) { + $pathDisplay = if ($currentPath) { $currentPath } else { '(not set)' } + Write-Host " [DRIFT] $folderName DestPath: $pathDisplay -> $($desired.Path)" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'FolderRedirection' + Folder = $folderName + Status = "DestPath: $pathDisplay -> $($desired.Path)" + } + } else { + Write-Host " [OK] $folderName -> $currentPath" -ForegroundColor Green + } + + # Check GrantExclusive + $desiredGrant = if ($desired.GrantExclusive) { '0x00000001' } else { '0x00000000' } + if ($section['GrantExclusive'] -ne $desiredGrant) { + Write-Host " [DRIFT] $folderName GrantExclusive: $($section['GrantExclusive']) -> $desiredGrant" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'FolderRedirection' + Folder = $folderName + Status = "GrantExclusive mismatch" + } + } + + # Check MoveContents + $desiredMove = if ($desired.MoveContents) { '0x00000001' } else { '0x00000000' } + if ($section['MoveContents'] -ne $desiredMove) { + Write-Host " [DRIFT] $folderName MoveContents: $($section['MoveContents']) -> $desiredMove" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'FolderRedirection' + Folder = $folderName + Status = "MoveContents mismatch" + } + } + } + + if ($diffs.Count -eq 0) { + Write-Host " [OK] Folder redirection matches desired state" -ForegroundColor Green + } else { + Write-Host " [DRIFT] $($diffs.Count) folder redirection difference(s) found" -ForegroundColor Red + } + + return $diffs +} diff --git a/gpo/lib/GPOHelper.ps1 b/gpo/lib/GPOHelper.ps1 new file mode 100644 index 0000000..ebd9289 --- /dev/null +++ b/gpo/lib/GPOHelper.ps1 @@ -0,0 +1,22 @@ +# GPOHelper.ps1 +# Loader: dot-sources the modular GPO helper library. +# All consumer scripts (Apply-GPOBaseline.ps1, Apply-DscBaseline.ps1, DSC configs) +# continue to load this single file -- zero breaking changes. + +$libDir = $PSScriptRoot + +# Core utilities must load first (other modules depend on them) +. (Join-Path $libDir 'GPOCore.ps1') + +# Feature modules (order-independent) +. (Join-Path $libDir 'GPOPolicy.ps1') +. (Join-Path $libDir 'GPOPermissions.ps1') +. (Join-Path $libDir 'GPOScripts.ps1') +. (Join-Path $libDir 'GPOAudit.ps1') +. (Join-Path $libDir 'GPOPreferences.ps1') +. (Join-Path $libDir 'GPOWmiFilter.ps1') +. (Join-Path $libDir 'GPOBackup.ps1') +. (Join-Path $libDir 'GPOFirewall.ps1') +. (Join-Path $libDir 'GPOAppLocker.ps1') +. (Join-Path $libDir 'GPOWdac.ps1') +. (Join-Path $libDir 'GPOFolderRedirection.ps1') diff --git a/gpo/lib/GPOPermissions.ps1 b/gpo/lib/GPOPermissions.ps1 new file mode 100644 index 0000000..e9139f7 --- /dev/null +++ b/gpo/lib/GPOPermissions.ps1 @@ -0,0 +1,351 @@ +# GPOPermissions.ps1 +# GPO links, management permissions, and security filtering. +# No dependencies on GPOCore.ps1 (uses only AD/GP cmdlets directly). + +function Normalize-GPOLinkTo { + <# + .SYNOPSIS + Normalizes the LinkTo value from settings.ps1 into a consistent + array of hashtables with Target, Order, and Enforced keys. + .DESCRIPTION + Supports three input formats: + - String: 'OU=...' (backward compatible, no order/enforcement management) + - Hashtable: @{ Target = 'OU=...'; Order = 1; Enforced = $true } + - Array: @( @{ Target = ... }, @{ Target = ... } ) + #> + param( + [Parameter(Mandatory)] + $LinkTo + ) + + # Single string + if ($LinkTo -is [string]) { + return @(@{ Target = $LinkTo; Order = $null; Enforced = $false }) + } + + # Single hashtable with Target key + if ($LinkTo -is [hashtable] -and $LinkTo.Target) { + return @(@{ + Target = $LinkTo.Target + Order = if ($LinkTo.ContainsKey('Order')) { $LinkTo.Order } else { $null } + Enforced = if ($LinkTo.ContainsKey('Enforced')) { $LinkTo.Enforced } else { $false } + }) + } + + # Array of hashtables or strings + if ($LinkTo -is [array]) { + $result = @() + foreach ($item in $LinkTo) { + if ($item -is [string]) { + $result += @{ Target = $item; Order = $null; Enforced = $false } + } elseif ($item -is [hashtable] -and $item.Target) { + $result += @{ + Target = $item.Target + Order = if ($item.ContainsKey('Order')) { $item.Order } else { $null } + Enforced = if ($item.ContainsKey('Enforced')) { $item.Enforced } else { $false } + } + } + } + return $result + } + + return @() +} + +function Ensure-GPOLink { + <# + .SYNOPSIS + Idempotently links a GPO to an OU. Optionally manages link order + and enforcement state. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [Parameter(Mandatory)] + [string]$TargetOU, + + $Order = $null, + + [bool]$Enforced = $false, + + [string]$Domain = (Get-ADDomain).DNSRoot + ) + + $inheritance = Get-GPInheritance -Target $TargetOU + $linked = $inheritance.GpoLinks | Where-Object { $_.DisplayName -eq $GPOName } + + if ($linked) { + $changed = $false + + # Check enforcement + $currentEnforced = $linked.Enforced -eq 'Yes' + if ($currentEnforced -ne $Enforced) { + $enforcedStr = if ($Enforced) { 'Yes' } else { 'No' } + Set-GPLink -Name $GPOName -Target $TargetOU -Domain $Domain -Enforced $enforcedStr | Out-Null + Write-Host " [UPDATED] Enforced: $currentEnforced -> $Enforced on $TargetOU" -ForegroundColor Yellow + $changed = $true + } + + # Check order (only if specified) + if ($null -ne $Order -and $linked.Order -ne $Order) { + Set-GPLink -Name $GPOName -Target $TargetOU -Domain $Domain -Order $Order | Out-Null + Write-Host " [UPDATED] Link order: $($linked.Order) -> $Order on $TargetOU" -ForegroundColor Yellow + $changed = $true + } + + if (-not $changed) { + Write-Host " Already linked: $GPOName -> $TargetOU" -ForegroundColor Green + } + } else { + $params = @{ + Name = $GPOName + Target = $TargetOU + Domain = $Domain + } + if ($null -ne $Order) { $params.Order = $Order } + if ($Enforced) { $params.Enforced = 'Yes' } + New-GPLink @params | Out-Null + Write-Host " Linked: $GPOName -> $TargetOU" -ForegroundColor Yellow + } +} + +function Compare-GPOLink { + <# + .SYNOPSIS + Checks whether a GPO is linked to the specified OU with correct + order and enforcement. Returns diff objects for any discrepancies. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [Parameter(Mandatory)] + [string]$TargetOU, + + $Order = $null, + + [bool]$Enforced = $false + ) + + $diffs = @() + $inheritance = Get-GPInheritance -Target $TargetOU + $linked = $inheritance.GpoLinks | Where-Object { $_.DisplayName -eq $GPOName } + + if (-not $linked) { + Write-Host " [DRIFT] Not linked: $GPOName -> $TargetOU" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'GPOLink' + GPO = $GPOName + TargetOU = $TargetOU + Status = 'Not linked' + } + return $diffs + } + + Write-Host " [OK] Linked: $GPOName -> $TargetOU" -ForegroundColor Green + + # Check enforcement + $currentEnforced = $linked.Enforced -eq 'Yes' + if ($currentEnforced -ne $Enforced) { + Write-Host " [DRIFT] Enforced: $currentEnforced -> $Enforced on $TargetOU" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'GPOLinkEnforced' + GPO = $GPOName + TargetOU = $TargetOU + Current = $currentEnforced + Desired = $Enforced + } + } + + # Check order (only if specified) + if ($null -ne $Order -and $linked.Order -ne $Order) { + Write-Host " [DRIFT] Link order: $($linked.Order) -> $Order on $TargetOU" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'GPOLinkOrder' + GPO = $GPOName + TargetOU = $TargetOU + Current = $linked.Order + Desired = $Order + } + } + + return $diffs +} + +# ------------------------------------------------------------------- +# Security Filtering (Deny Apply Group Policy) +# ------------------------------------------------------------------- + +function Ensure-GPOSecurityFiltering { + <# + .SYNOPSIS + Adds Deny Apply Group Policy ACEs to a GPO for specified groups. + Uses the AD ACL on the GPO object -- Set-GPPermission doesn't support Deny. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [string[]]$DenyApply = @() + ) + + if ($DenyApply.Count -eq 0) { return } + + $gpo = Get-GPO -Name $GPOName -ErrorAction Stop + $domainDN = (Get-ADDomain).DistinguishedName + $gpoADPath = "AD:CN={$($gpo.Id)},CN=Policies,CN=System,$domainDN" + $acl = Get-Acl $gpoADPath + + # Apply Group Policy extended right GUID + $applyGpoGuid = [Guid]'edacfd8f-ffb3-11d1-b41d-00a0c968f939' + + foreach ($groupName in $DenyApply) { + $group = Get-ADGroup -Identity $groupName -ErrorAction Stop + $sid = New-Object System.Security.Principal.SecurityIdentifier($group.SID) + + # Check if a Deny rule already exists for this SID + extended right + $alreadyDenied = $acl.Access | Where-Object { + $_.AccessControlType -eq 'Deny' -and + $_.ObjectType -eq $applyGpoGuid -and + $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value -eq $sid.Value + } + + if ($alreadyDenied) { + Write-Host " [OK] $groupName already denied Apply on GPO: $GPOName" -ForegroundColor Green + } else { + $denyRule = New-Object System.DirectoryServices.ActiveDirectoryAccessRule( + $sid, + [System.DirectoryServices.ActiveDirectoryRights]::ExtendedRight, + [System.Security.AccessControl.AccessControlType]::Deny, + $applyGpoGuid + ) + $acl.AddAccessRule($denyRule) + Set-Acl $gpoADPath $acl + Write-Host " [DENY] $groupName denied Apply on GPO: $GPOName" -ForegroundColor Yellow + } + } +} + +function Compare-GPOSecurityFiltering { + <# + .SYNOPSIS + Checks whether Deny Apply Group Policy ACEs exist for the specified groups. + Returns diffs for any missing deny rules. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [string[]]$DenyApply = @() + ) + + $diffs = @() + if ($DenyApply.Count -eq 0) { return $diffs } + + $gpo = Get-GPO -Name $GPOName -ErrorAction Stop + $domainDN = (Get-ADDomain).DistinguishedName + $gpoADPath = "AD:CN={$($gpo.Id)},CN=Policies,CN=System,$domainDN" + $acl = Get-Acl $gpoADPath + + $applyGpoGuid = [Guid]'edacfd8f-ffb3-11d1-b41d-00a0c968f939' + + foreach ($groupName in $DenyApply) { + $group = Get-ADGroup -Identity $groupName -ErrorAction Stop + $sid = New-Object System.Security.Principal.SecurityIdentifier($group.SID) + + $found = $acl.Access | Where-Object { + $_.AccessControlType -eq 'Deny' -and + $_.ObjectType -eq $applyGpoGuid -and + $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value -eq $sid.Value + } + + if ($found) { + Write-Host " [OK] $groupName denied Apply on GPO: $GPOName" -ForegroundColor Green + } else { + Write-Host " [DRIFT] $groupName not denied Apply on GPO: $GPOName" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'SecurityFiltering' + GPO = $GPOName + Group = $groupName + Status = 'Missing Deny' + } + } + } + + return $diffs +} + +# ------------------------------------------------------------------- +# GPO Management Permissions +# ------------------------------------------------------------------- + +function Ensure-GPOManagementPermission { + <# + .SYNOPSIS + Ensures a security group has GpoEditDeleteModifySecurity on a GPO. + Idempotent: no-op if the permission already exists. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [Parameter(Mandatory)] + [string]$GroupName + ) + + $hasPermission = $false + try { + $perm = Get-GPPermission -Name $GPOName -TargetName $GroupName -TargetType Group -ErrorAction Stop + if ($perm.Permission -eq 'GpoEditDeleteModifySecurity') { + $hasPermission = $true + } + } catch { + # Group has no permissions on this GPO + } + + if ($hasPermission) { + Write-Host " [OK] $GroupName has edit rights on GPO: $GPOName" -ForegroundColor Green + } else { + Set-GPPermission -Name $GPOName -PermissionLevel GpoEditDeleteModifySecurity ` + -TargetName $GroupName -TargetType Group -Replace | Out-Null + Write-Host " [GRANTED] $GroupName edit rights on GPO: $GPOName" -ForegroundColor Yellow + } +} + +function Compare-GPOManagementPermission { + <# + .SYNOPSIS + Checks whether a security group has GpoEditDeleteModifySecurity on a GPO. + Returns a diff object if the permission is missing. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [Parameter(Mandatory)] + [string]$GroupName + ) + + $hasPermission = $false + try { + $perm = Get-GPPermission -Name $GPOName -TargetName $GroupName -TargetType Group -ErrorAction Stop + if ($perm.Permission -eq 'GpoEditDeleteModifySecurity') { + $hasPermission = $true + } + } catch { + # Group has no permissions on this GPO + } + + if ($hasPermission) { + Write-Host " [OK] $GroupName has edit rights on GPO: $GPOName" -ForegroundColor Green + } else { + Write-Host " [DRIFT] $GroupName missing edit rights on GPO: $GPOName" -ForegroundColor Red + return [PSCustomObject]@{ + Type = 'ManagementPermission' + GPO = $GPOName + Group = $GroupName + Status = 'Missing GpoEditDeleteModifySecurity' + } + } +} diff --git a/gpo/lib/GPOPolicy.ps1 b/gpo/lib/GPOPolicy.ps1 new file mode 100644 index 0000000..5c178d9 --- /dev/null +++ b/gpo/lib/GPOPolicy.ps1 @@ -0,0 +1,377 @@ +# GPOPolicy.ps1 +# GptTmpl.inf handling and registry-based Administrative Template settings. +# Depends on: GPOCore.ps1 (Get-GPOSysvolPath, Get-GPOSecurityTemplatePath) + +function Read-GptTmplInf { + <# + .SYNOPSIS + Parses a GptTmpl.inf file into a nested hashtable keyed by section name. + #> + param( + [Parameter(Mandatory)] + [string]$Path + ) + + if (-not (Test-Path $Path)) { + return @{} + } + + $result = @{} + $currentSection = $null + + # GptTmpl.inf is UTF-16LE + $lines = [System.IO.File]::ReadAllLines($Path, [System.Text.Encoding]::Unicode) + + foreach ($line in $lines) { + $line = $line.Trim() + if ($line -match '^\[(.+)\]$') { + $currentSection = $Matches[1] + if (-not $result.Contains($currentSection)) { + $result[$currentSection] = [ordered]@{} + } + } + elseif ($line -match '^(.+?)\s*=\s*(.*)$' -and $currentSection) { + $result[$currentSection][$Matches[1].Trim()] = $Matches[2].Trim() + } + } + + return $result +} + +function ConvertTo-GptTmplInf { + <# + .SYNOPSIS + Converts a settings hashtable into GptTmpl.inf content string. + .DESCRIPTION + Takes the SecurityPolicy hashtable from a settings.ps1 file and produces + the INI-format content for a GptTmpl.inf file. + #> + param( + [Parameter(Mandatory)] + [hashtable]$Settings + ) + + $sb = [System.Text.StringBuilder]::new() + [void]$sb.AppendLine('[Unicode]') + [void]$sb.AppendLine('Unicode=yes') + + # Ordered section output + $sectionOrder = @( + 'System Access' + 'Kerberos Policy' + 'Event Audit' + 'Registry Values' + 'Privilege Rights' + 'Group Membership' + ) + + foreach ($section in $sectionOrder) { + if ($Settings.Contains($section)) { + [void]$sb.AppendLine("[$section]") + foreach ($key in $Settings[$section].Keys) { + [void]$sb.AppendLine("$key = $($Settings[$section][$key])") + } + } + } + + # Include any sections not in the predefined order + foreach ($section in $Settings.Keys) { + if ($section -notin $sectionOrder -and $section -ne 'Unicode') { + [void]$sb.AppendLine("[$section]") + foreach ($key in $Settings[$section].Keys) { + [void]$sb.AppendLine("$key = $($Settings[$section][$key])") + } + } + } + + [void]$sb.AppendLine('[Version]') + [void]$sb.AppendLine('signature="$CHICAGO$"') + [void]$sb.AppendLine('Revision=1') + + return $sb.ToString() +} + +function Set-GPOSecurityPolicy { + <# + .SYNOPSIS + Writes security policy settings to a GPO's GptTmpl.inf in SYSVOL + and bumps the GPO version number in AD so clients pick up the change. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [Parameter(Mandatory)] + [hashtable]$SecurityPolicy, + + [string]$Domain = (Get-ADDomain).DNSRoot + ) + + $infPath = Get-GPOSecurityTemplatePath -GPOName $GPOName -Domain $Domain + $infDir = Split-Path $infPath -Parent + + # Ensure directory structure exists + if (-not (Test-Path $infDir)) { + New-Item -ItemType Directory -Path $infDir -Force | Out-Null + } + + # Generate and write the inf content as UTF-16LE (required by Windows) + $content = ConvertTo-GptTmplInf -Settings $SecurityPolicy + [System.IO.File]::WriteAllText($infPath, $content, [System.Text.Encoding]::Unicode) + + Write-Host " Written: $infPath" -ForegroundColor Green +} + +function Compare-GPOSecurityPolicy { + <# + .SYNOPSIS + Compares desired security policy settings against what's currently + in the GPO's GptTmpl.inf. Returns differences. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [Parameter(Mandatory)] + [hashtable]$SecurityPolicy, + + [string]$Domain = (Get-ADDomain).DNSRoot + ) + + $infPath = Get-GPOSecurityTemplatePath -GPOName $GPOName -Domain $Domain + $current = Read-GptTmplInf -Path $infPath + $differences = @() + + foreach ($section in $SecurityPolicy.Keys) { + foreach ($key in $SecurityPolicy[$section].Keys) { + $desired = [string]$SecurityPolicy[$section][$key] + $actual = $null + + if ($current.Contains($section) -and $current[$section].Contains($key)) { + $actual = [string]$current[$section][$key] + } + + if ($actual -ne $desired) { + $differences += [PSCustomObject]@{ + Section = $section + Setting = $key + Current = if ($null -eq $actual) { '(not set)' } else { $actual } + Desired = $desired + } + } + } + } + + return $differences +} + +# ------------------------------------------------------------------- +# Restricted Groups ([Group Membership] section in GptTmpl.inf) +# ------------------------------------------------------------------- + +function ConvertTo-RestrictedGroupEntries { + <# + .SYNOPSIS + Converts a friendly RestrictedGroups hashtable into [Group Membership] + key-value pairs for GptTmpl.inf. + .DESCRIPTION + GptTmpl.inf [Group Membership] uses SID-based keys: + *S-1-5-32-544__Members = *S-1-5-21-xxx,*S-1-5-21-yyy + *S-1-5-32-544__Memberof = + This function resolves group/account names to SIDs automatically. + #> + param( + [Parameter(Mandatory)] + [hashtable]$RestrictedGroups + ) + + $entries = [ordered]@{} + + foreach ($groupName in $RestrictedGroups.Keys) { + $groupDef = $RestrictedGroups[$groupName] + + # Resolve target group to SID + try { + $ntAccount = New-Object System.Security.Principal.NTAccount($groupName) + $groupSid = $ntAccount.Translate([System.Security.Principal.SecurityIdentifier]).Value + } catch { + throw "RestrictedGroups: cannot resolve group '$groupName' to a SID: $_" + } + + # Members + $memberSids = @() + if ($groupDef.Members) { + foreach ($member in $groupDef.Members) { + try { + $memberNt = New-Object System.Security.Principal.NTAccount($member) + $memberSid = $memberNt.Translate([System.Security.Principal.SecurityIdentifier]).Value + $memberSids += "*$memberSid" + } catch { + throw "RestrictedGroups: cannot resolve member '$member' of group '$groupName' to a SID: $_" + } + } + } + $entries["*${groupSid}__Members"] = $memberSids -join ',' + + # Memberof + $memberofSids = @() + if ($groupDef.Memberof) { + foreach ($parent in $groupDef.Memberof) { + try { + $parentNt = New-Object System.Security.Principal.NTAccount($parent) + $parentSid = $parentNt.Translate([System.Security.Principal.SecurityIdentifier]).Value + $memberofSids += "*$parentSid" + } catch { + throw "RestrictedGroups: cannot resolve parent group '$parent' of '$groupName' to a SID: $_" + } + } + } + $entries["*${groupSid}__Memberof"] = $memberofSids -join ',' + } + + return $entries +} + +# ------------------------------------------------------------------- +# Registry-Based Settings (Administrative Templates) +# ------------------------------------------------------------------- + +function Compare-GPORegistrySettings { + <# + .SYNOPSIS + Compares desired registry-based (Administrative Template) settings + against the current values in a GPO. Returns differences. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [Parameter(Mandatory)] + [array]$RegistrySettings, + + [string]$Domain = (Get-ADDomain).DNSRoot + ) + + $differences = @() + + foreach ($reg in $RegistrySettings) { + $desiredDisplay = "$($reg.Value) ($($reg.Type))" + + try { + $current = Get-GPRegistryValue ` + -Name $GPOName ` + -Domain $Domain ` + -Key $reg.Key ` + -ValueName $reg.ValueName ` + -ErrorAction Stop + + $actualValue = $current.Value + $actualType = $current.Type.ToString() + + if ([string]$actualValue -ne [string]$reg.Value -or $actualType -ne $reg.Type) { + $differences += [PSCustomObject]@{ + Key = $reg.Key + ValueName = $reg.ValueName + Current = "$actualValue ($actualType)" + Desired = $desiredDisplay + } + } + } catch { + # Setting doesn't exist in the GPO yet + $differences += [PSCustomObject]@{ + Key = $reg.Key + ValueName = $reg.ValueName + Current = '(not set)' + Desired = $desiredDisplay + } + } + } + + # --- Stale value detection --- + # For each unique key in settings, check if the GPO has values not in the declared set + $uniqueKeys = $RegistrySettings | ForEach-Object { $_.Key } | Select-Object -Unique + $declaredLookup = @{} + foreach ($reg in $RegistrySettings) { + $declaredLookup["$($reg.Key)|$($reg.ValueName)"] = $true + } + + foreach ($key in $uniqueKeys) { + try { + $currentValues = Get-GPRegistryValue -Name $GPOName -Domain $Domain ` + -Key $key -ErrorAction Stop + } catch { continue } + + foreach ($val in $currentValues) { + if (-not $val.ValueName) { continue } # skip subkey entries + $lookupKey = "$key|$($val.ValueName)" + if (-not $declaredLookup.ContainsKey($lookupKey)) { + $differences += [PSCustomObject]@{ + Key = $key + ValueName = $val.ValueName + Current = "$($val.Value) ($($val.Type))" + Desired = '(stale -- should be removed)' + } + } + } + } + + return $differences +} + +function Set-GPORegistrySettings { + <# + .SYNOPSIS + Applies registry-based (Administrative Template) settings to a GPO + using Set-GPRegistryValue. With -Cleanup, removes stale values under + managed keys that are not in the declared settings. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [Parameter(Mandatory)] + [array]$RegistrySettings, + + [switch]$Cleanup, + + [string]$Domain = (Get-ADDomain).DNSRoot + ) + + foreach ($reg in $RegistrySettings) { + Set-GPRegistryValue ` + -Name $GPOName ` + -Domain $Domain ` + -Key $reg.Key ` + -ValueName $reg.ValueName ` + -Type $reg.Type ` + -Value $reg.Value | Out-Null + + Write-Host " Set: $($reg.Key)\$($reg.ValueName) = $($reg.Value)" -ForegroundColor Green + } + + # Remove stale values under managed keys + if ($Cleanup) { + $declaredLookup = @{} + foreach ($reg in $RegistrySettings) { + $declaredLookup["$($reg.Key)|$($reg.ValueName)"] = $true + } + $uniqueKeys = $RegistrySettings | ForEach-Object { $_.Key } | Select-Object -Unique + + foreach ($key in $uniqueKeys) { + try { + $currentValues = Get-GPRegistryValue -Name $GPOName -Domain $Domain ` + -Key $key -ErrorAction Stop + } catch { continue } + + foreach ($val in $currentValues) { + if (-not $val.ValueName) { continue } + $lookupKey = "$key|$($val.ValueName)" + if (-not $declaredLookup.ContainsKey($lookupKey)) { + Remove-GPRegistryValue -Name $GPOName -Domain $Domain ` + -Key $key -ValueName $val.ValueName | Out-Null + Write-Host " [REMOVED] Stale: $key\$($val.ValueName)" -ForegroundColor Yellow + } + } + } + } +} diff --git a/gpo/lib/GPOPreferences.ps1 b/gpo/lib/GPOPreferences.ps1 new file mode 100644 index 0000000..4e463ee --- /dev/null +++ b/gpo/lib/GPOPreferences.ps1 @@ -0,0 +1,837 @@ +# GPOPreferences.ps1 +# Group Policy Preferences XML generation for 10 types. +# Depends on: GPOCore.ps1 (Get-GPOSysvolPath, Add-GPOExtensionGuids) + +$Script:GppToolGuid = '{CAB54552-DEEA-4691-817E-ED4A4D1AFC72}' + +$Script:GppActionCode = @{ Create = 'C'; Replace = 'R'; Update = 'U'; Delete = 'D' } +$Script:GppActionImage = @{ Create = '0'; Replace = '1'; Update = '2'; Delete = '3' } + +$Script:GppTypeInfo = @{ + ScheduledTasks = @{ + RootClsid = '{CC63F200-7309-4ba0-B154-A71CD118DBCC}' + ItemClsid = '{D8896631-B747-47a7-84A6-C155337F3BC8}' + SysvolDir = 'ScheduledTasks' + FileName = 'ScheduledTasks.xml' + CseGuid = '{AADCED64-746C-4633-A97C-D61349046527}' + NameKey = 'Name' + } + DriveMaps = @{ + RootClsid = '{8FDDCC1A-0C3C-43cd-A6B4-71A6DF20DA8C}' + ItemClsid = '{935D1B74-9CB8-4e3c-9914-7DD559B7A417}' + SysvolDir = 'Drives' + FileName = 'Drives.xml' + CseGuid = '{5794DAFD-BE60-433f-88A2-1A31939AC01F}' + NameKey = 'Letter' + } + EnvironmentVariables = @{ + RootClsid = '{D76B9641-3288-4f75-942D-087DE603E3EA}' + ItemClsid = '{B1A3FA3F-E5D1-41ea-88D1-55CE96B6C78B}' + SysvolDir = 'EnvironmentVariables' + FileName = 'EnvironmentVariables.xml' + CseGuid = '{0E28E245-9368-4853-AD84-6DA3BA35BB75}' + NameKey = 'Name' + } + Services = @{ + RootClsid = '{2CFB484A-4E86-4eb1-8B6A-E1535488BDBF}' + ItemClsid = '{AB6F0B67-D4E1-4f59-A9E5-F6BD3A72F4FF}' + SysvolDir = 'Services' + FileName = 'Services.xml' + CseGuid = '{91FBB303-0CD5-4055-BF42-E512A681B325}' + NameKey = 'ServiceName' + } + Printers = @{ + RootClsid = '{1F577D12-3D1B-471e-A1B7-060317597B9C}' + ItemClsid = '{9A5E9697-9095-436d-A0EE-4D128FDFBCE5}' + SysvolDir = 'Printers' + FileName = 'Printers.xml' + CseGuid = '{A8C42CEA-CDB8-4388-97F4-5831F933DA84}' + NameKey = 'Path' + } + Shortcuts = @{ + RootClsid = '{872ECB34-B2EC-401b-A585-D32574AA90EE}' + ItemClsid = '{4F2F7C55-2790-433e-8127-0739D1CFA327}' + SysvolDir = 'Shortcuts' + FileName = 'Shortcuts.xml' + CseGuid = '{C418DD9D-0D14-4EFB-8FBF-CFE535C8FAC7}' + NameKey = 'Name' + } + Files = @{ + RootClsid = '{215B2E53-57CE-475c-80FE-9EEC14635851}' + ItemClsid = '{50BE44C8-567A-4ed1-B1D0-9234FE1F38AF}' + SysvolDir = 'Files' + FileName = 'Files.xml' + CseGuid = '{7150F9BF-48AD-4DA4-A49C-29EF4A8369BA}' + NameKey = 'TargetPath' + } + NetworkShares = @{ + RootClsid = '{520870D8-A6E7-47e8-A8D8-E6A4E76EAEC2}' + ItemClsid = '{2888C5E7-94FC-4739-90AA-2C1536D68BC0}' + SysvolDir = 'NetworkShares' + FileName = 'NetworkShares.xml' + CseGuid = '{6A4C88C6-C502-4F74-8F60-2CB23EDC24E2}' + NameKey = 'Name' + } + RegistryItems = @{ + RootClsid = '{A3CCFC41-DFDB-43a5-8D26-0FE8B954DA51}' + ItemClsid = '{9CD4B2F4-923D-47f5-A062-E897DD1DAD50}' + SysvolDir = 'Registry' + FileName = 'Registry.xml' + CseGuid = '{B087BE9D-ED37-454F-AF9C-04291E351182}' + NameKey = 'Name' + } + LocalUsersAndGroups = @{ + RootClsid = '{3125E937-EB16-4b4c-9934-544FC6D24D26}' + ItemClsid = '{6D4A79E4-529C-4481-ABD0-F5BD7EA93BA7}' + SysvolDir = 'Groups' + FileName = 'Groups.xml' + CseGuid = '{17D89FEC-5C44-4972-B12D-241CAEF74509}' + NameKey = 'GroupName' + } +} + +function ConvertTo-ILTFilterXml { + <# + .SYNOPSIS + Converts a Filters array from a GPP item into the ILT XML block. + Returns empty string if no filters are defined. + .DESCRIPTION + Supports SecurityGroup, OrgUnit, Computer, User, OperatingSystem, and WMI + filter types. SecurityGroup filters resolve names to SIDs at runtime. + #> + param( + [array]$Filters + ) + + if (-not $Filters -or $Filters.Count -eq 0) { return '' } + + $esc = [System.Security.SecurityElement] + + $filterXml = foreach ($f in $Filters) { + $bool = if ($f.Bool) { $f.Bool } else { 'AND' } + $not = if ($f.Not) { '1' } else { '0' } + $name = $esc::Escape($f.Name) + + switch ($f.Type) { + 'SecurityGroup' { + try { + $ntAccount = New-Object System.Security.Principal.NTAccount($f.Name) + $sid = $ntAccount.Translate([System.Security.Principal.SecurityIdentifier]).Value + } catch { + throw "ILT Filter: cannot resolve group '$($f.Name)' to a SID: $_" + } + $userContext = if ($f.ContainsKey('UserContext') -and -not $f.UserContext) { '0' } else { '1' } + $primaryGroup = if ($f.PrimaryGroup) { '1' } else { '0' } + $localGroup = if ($f.LocalGroup) { '1' } else { '0' } + " " + } + 'OrgUnit' { + $type = if ($f.ContainsKey('OUType')) { $f.OUType } else { '' } + " " + } + 'Computer' { + $type = if ($f.ContainsKey('NameType')) { $f.NameType } else { 'NETBIOS' } + " " + } + 'User' { + $type = if ($f.ContainsKey('NameType')) { $f.NameType } else { 'NETBIOS' } + " " + } + 'OperatingSystem' { + $edition = if ($f.Edition) { $esc::Escape($f.Edition) } else { '' } + $version = if ($f.Version) { $esc::Escape($f.Version) } else { '' } + " " + } + 'WMI' { + $property = if ($f.Property) { $esc::Escape($f.Property) } else { '' } + $namespace = if ($f.Namespace) { $esc::Escape($f.Namespace) } else { 'root\CIMv2' } + $query = $esc::Escape($f.Query) + " " + } + default { + Write-Host " [WARN] Unknown ILT filter type: $($f.Type)" -ForegroundColor Yellow + $null + } + } + } + + $validFilters = @($filterXml | Where-Object { $_ }) + if ($validFilters.Count -eq 0) { return '' } + + return "`n `n$($validFilters -join "`n")`n " +} + +function ConvertTo-ScheduledTaskXml { + <# + .SYNOPSIS + Generates ScheduledTasks.xml GPP content for TaskV2 entries. + #> + param( + [Parameter(Mandatory)] + [array]$Tasks, + + [string]$Scope = 'Machine' + ) + + $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + $esc = [System.Security.SecurityElement] + $userContext = if ($Scope -eq 'User') { '1' } else { '0' } + + $itemsXml = foreach ($task in $Tasks) { + $uid = "{$([Guid]::NewGuid().ToString().ToUpper())}" + $action = $Script:GppActionCode[$task.Action] + $image = $Script:GppActionImage[$task.Action] + $name = $esc::Escape($task.Name) + $runAs = if ($task.RunAs) { $task.RunAs } else { 'NT AUTHORITY\System' } + $command = $esc::Escape($task.Command) + $arguments = if ($task.Arguments) { $esc::Escape($task.Arguments) } else { '' } + + $trigger = switch ($task.Trigger) { + 'AtStartup' { 'true' } + 'AtLogon' { 'true' } + default { 'true' } + } + + $filterBlock = ConvertTo-ILTFilterXml -Filters $task.Filters + + @" + + + + + + + $runAs + S4U + HighestAvailable + + + + + PT10M + PT1H + true + false + + IgnoreNew + false + false + true + true + true + true + false + PT72H + 7 + + $trigger + + + $command + $arguments + + + + $filterBlock + +"@ + } + + return @" + + +$($itemsXml -join "`n") + +"@ +} + +function ConvertTo-DriveMapXml { + <# + .SYNOPSIS + Generates Drives.xml GPP content for drive map entries. + #> + param( + [Parameter(Mandatory)] + [array]$Drives + ) + + $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + $esc = [System.Security.SecurityElement] + + $itemsXml = foreach ($drive in $Drives) { + $uid = "{$([Guid]::NewGuid().ToString().ToUpper())}" + $action = $Script:GppActionCode[$drive.Action] + $image = $Script:GppActionImage[$drive.Action] + $letter = $drive.Letter.TrimEnd(':') + $path = $esc::Escape($drive.Path) + $label = if ($drive.Label) { $esc::Escape($drive.Label) } else { '' } + $persistent = if ($drive.Reconnect) { '1' } else { '0' } + + $filterBlock = ConvertTo-ILTFilterXml -Filters $drive.Filters + + @" + + $filterBlock + +"@ + } + + return @" + + +$($itemsXml -join "`n") + +"@ +} + +function ConvertTo-EnvironmentVariableXml { + <# + .SYNOPSIS + Generates EnvironmentVariables.xml GPP content. + #> + param( + [Parameter(Mandatory)] + [array]$Variables, + + [string]$Scope = 'Machine' + ) + + $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + $esc = [System.Security.SecurityElement] + $userContext = if ($Scope -eq 'User') { '1' } else { '0' } + $user = if ($Scope -eq 'User') { '1' } else { '0' } + + $itemsXml = foreach ($var in $Variables) { + $uid = "{$([Guid]::NewGuid().ToString().ToUpper())}" + $action = $Script:GppActionCode[$var.Action] + $image = $Script:GppActionImage[$var.Action] + $name = $esc::Escape($var.Name) + $value = $esc::Escape($var.Value) + + $filterBlock = ConvertTo-ILTFilterXml -Filters $var.Filters + + @" + + $filterBlock + +"@ + } + + return @" + + +$($itemsXml -join "`n") + +"@ +} + +function ConvertTo-ServiceXml { + <# + .SYNOPSIS + Generates Services.xml GPP content for NTService entries. + #> + param( + [Parameter(Mandatory)] + [array]$Services + ) + + $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + $esc = [System.Security.SecurityElement] + $startupMap = @{ + Automatic = 'AUTOMATIC' + Manual = 'MANUAL' + Disabled = 'DISABLED' + } + + $itemsXml = foreach ($svc in $Services) { + $uid = "{$([Guid]::NewGuid().ToString().ToUpper())}" + $action = $Script:GppActionCode[$svc.Action] + $image = $Script:GppActionImage[$svc.Action] + $serviceName = $esc::Escape($svc.ServiceName) + $startupType = $startupMap[$svc.StartupType] + if (-not $startupType) { $startupType = 'NOCHANGE' } + + $filterBlock = ConvertTo-ILTFilterXml -Filters $svc.Filters + + @" + + $filterBlock + +"@ + } + + return @" + + +$($itemsXml -join "`n") + +"@ +} + +function ConvertTo-PrinterXml { + <# + .SYNOPSIS + Generates Printers.xml GPP content for shared printer entries. + #> + param( + [Parameter(Mandatory)] + [array]$Printers + ) + + $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + $esc = [System.Security.SecurityElement] + + $itemsXml = foreach ($printer in $Printers) { + $uid = "{$([Guid]::NewGuid().ToString().ToUpper())}" + $action = $Script:GppActionCode[$printer.Action] + $image = $Script:GppActionImage[$printer.Action] + $path = $esc::Escape($printer.Path) + # Derive name from last segment of UNC path + $name = ($printer.Path -split '\\' | Where-Object { $_ })[-1] + $default = if ($printer.Default) { '1' } else { '0' } + $skipLocal = if ($printer.SkipLocal) { '1' } else { '0' } + $comment = if ($printer.Comment) { $esc::Escape($printer.Comment) } else { '' } + $location = if ($printer.Location) { $esc::Escape($printer.Location) } else { '' } + + $filterBlock = ConvertTo-ILTFilterXml -Filters $printer.Filters + + @" + + $filterBlock + +"@ + } + + return @" + + +$($itemsXml -join "`n") + +"@ +} + +function ConvertTo-ShortcutXml { + <# + .SYNOPSIS + Generates Shortcuts.xml GPP content for shortcut entries. + #> + param( + [Parameter(Mandatory)] + [array]$Shortcuts, + + [string]$Scope = 'User' + ) + + $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + $esc = [System.Security.SecurityElement] + $userContext = if ($Scope -eq 'User') { '1' } else { '0' } + + $itemsXml = foreach ($shortcut in $Shortcuts) { + $uid = "{$([Guid]::NewGuid().ToString().ToUpper())}" + $action = $Script:GppActionCode[$shortcut.Action] + $image = $Script:GppActionImage[$shortcut.Action] + $name = $esc::Escape($shortcut.Name) + $targetType = if ($shortcut.TargetType) { $shortcut.TargetType } else { 'FILESYSTEM' } + $targetPath = $esc::Escape($shortcut.TargetPath) + $shortcutPath = $esc::Escape($shortcut.ShortcutPath) + $arguments = if ($shortcut.Arguments) { $esc::Escape($shortcut.Arguments) } else { '' } + $startIn = if ($shortcut.StartIn) { $esc::Escape($shortcut.StartIn) } else { '' } + $comment = if ($shortcut.Comment) { $esc::Escape($shortcut.Comment) } else { '' } + $iconPath = if ($shortcut.IconPath) { $esc::Escape($shortcut.IconPath) } else { '' } + $iconIndex = if ($shortcut.IconIndex) { $shortcut.IconIndex } else { '0' } + + $filterBlock = ConvertTo-ILTFilterXml -Filters $shortcut.Filters + + @" + + $filterBlock + +"@ + } + + return @" + + +$($itemsXml -join "`n") + +"@ +} + +function ConvertTo-FileXml { + <# + .SYNOPSIS + Generates Files.xml GPP content for file copy/replace entries. + #> + param( + [Parameter(Mandatory)] + [array]$Files, + + [string]$Scope = 'Machine' + ) + + $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + $esc = [System.Security.SecurityElement] + $userContext = if ($Scope -eq 'User') { '1' } else { '0' } + + $itemsXml = foreach ($file in $Files) { + $uid = "{$([Guid]::NewGuid().ToString().ToUpper())}" + $action = $Script:GppActionCode[$file.Action] + $image = $Script:GppActionImage[$file.Action] + $fromPath = $esc::Escape($file.FromPath) + $targetPath = $esc::Escape($file.TargetPath) + $readOnly = if ($file.ReadOnly) { '1' } else { '0' } + $hidden = if ($file.Hidden) { '1' } else { '0' } + + $filterBlock = ConvertTo-ILTFilterXml -Filters $file.Filters + + @" + + +"@ + } + + return @" + + +$($itemsXml -join "`n") + +"@ +} + +function ConvertTo-NetworkShareXml { + <# + .SYNOPSIS + Generates NetworkShares.xml GPP content for network share entries. + #> + param( + [Parameter(Mandatory)] + [array]$Shares + ) + + $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + $esc = [System.Security.SecurityElement] + + $itemsXml = foreach ($share in $Shares) { + $uid = "{$([Guid]::NewGuid().ToString().ToUpper())}" + $action = $Script:GppActionCode[$share.Action] + $image = $Script:GppActionImage[$share.Action] + $name = $esc::Escape($share.Name) + $path = $esc::Escape($share.Path) + $comment = if ($share.Comment) { $esc::Escape($share.Comment) } else { '' } + $allRegular = if ($share.AllRegular) { $share.AllRegular } else { '' } + $allHidden = if ($share.AllHidden) { $share.AllHidden } else { '' } + $allAdminDrive = if ($share.AllAdminDrive) { $share.AllAdminDrive } else { '' } + $limitUsers = if ($share.LimitUsers) { $share.LimitUsers } else { '0' } + + $filterBlock = ConvertTo-ILTFilterXml -Filters $share.Filters + + @" + + $filterBlock + +"@ + } + + return @" + + +$($itemsXml -join "`n") + +"@ +} + +function ConvertTo-RegistryItemXml { + <# + .SYNOPSIS + Generates Registry.xml GPP content for registry preference items. + These are GPP Registry items (not Administrative Templates). + #> + param( + [Parameter(Mandatory)] + [array]$Items, + + [string]$Scope = 'Machine' + ) + + $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + $esc = [System.Security.SecurityElement] + $userContext = if ($Scope -eq 'User') { '1' } else { '0' } + + $itemsXml = foreach ($item in $Items) { + $uid = "{$([Guid]::NewGuid().ToString().ToUpper())}" + $action = $Script:GppActionCode[$item.Action] + $image = $Script:GppActionImage[$item.Action] + $hive = $esc::Escape($item.Hive) + $key = $esc::Escape($item.Key) + $name = if ($item.Name) { $esc::Escape($item.Name) } else { '' } + $type = if ($item.Type) { $item.Type } else { 'REG_SZ' } + $value = if ($item.Value) { $esc::Escape($item.Value) } else { '' } + $displayName = if ($name) { $name } else { '(Default)' } + + $filterBlock = ConvertTo-ILTFilterXml -Filters $item.Filters + + @" + + $filterBlock + +"@ + } + + return @" + + +$($itemsXml -join "`n") + +"@ +} + +function ConvertTo-LocalGroupXml { + <# + .SYNOPSIS + Generates Groups.xml GPP content for local user/group management. + Supports ADD/REMOVE individual members (unlike RestrictedGroups + which enforces exact membership). + #> + param( + [Parameter(Mandatory)] + [array]$Groups + ) + + $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + $esc = [System.Security.SecurityElement] + + $itemsXml = foreach ($group in $Groups) { + $uid = "{$([Guid]::NewGuid().ToString().ToUpper())}" + $action = $Script:GppActionCode[$group.Action] + $image = $Script:GppActionImage[$group.Action] + $groupName = $esc::Escape($group.GroupName) + $newName = if ($group.NewName) { $esc::Escape($group.NewName) } else { '' } + $description = if ($group.Description) { $esc::Escape($group.Description) } else { '' } + $deleteAllUsers = if ($group.DeleteAllUsers) { '1' } else { '0' } + $deleteAllGroups = if ($group.DeleteAllGroups) { '1' } else { '0' } + $removeAccounts = if ($group.RemoveAccounts) { '1' } else { '0' } + + # Build block + $membersXml = '' + if ($group.Members -and $group.Members.Count -gt 0) { + $memberLines = foreach ($m in $group.Members) { + $memberName = $esc::Escape($m.Name) + $memberAction = $m.Action.ToUpper() + $sid = '' + try { + $ntAccount = New-Object System.Security.Principal.NTAccount($m.Name) + $sid = $ntAccount.Translate([System.Security.Principal.SecurityIdentifier]).Value + } catch { + Write-Host " [WARN] Cannot resolve '$($m.Name)' to SID -- leaving empty" -ForegroundColor Yellow + } + " " + } + $membersXml = "`n `n$($memberLines -join "`n")`n " + } + + $filterBlock = ConvertTo-ILTFilterXml -Filters $group.Filters + + @" + + $membersXml + $filterBlock + +"@ + } + + return @" + + +$($itemsXml -join "`n") + +"@ +} + +function Set-GPOPreferences { + <# + .SYNOPSIS + Writes Group Policy Preferences XML files to SYSVOL. + Supports 10 GPP types: ScheduledTasks, DriveMaps, EnvironmentVariables, + Services, Printers, Shortcuts, Files, NetworkShares, RegistryItems, + LocalUsersAndGroups. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [Parameter(Mandatory)] + [hashtable]$Preferences, + + [string]$Domain = (Get-ADDomain).DNSRoot + ) + + $sysvolPath = Get-GPOSysvolPath -GPOName $GPOName -Domain $Domain + $utf8Bom = [System.Text.UTF8Encoding]::new($true) + + foreach ($typeName in $Preferences.Keys) { + $typeInfo = $Script:GppTypeInfo[$typeName] + if (-not $typeInfo) { + Write-Host " [WARN] Unknown preference type: $typeName" -ForegroundColor Yellow + continue + } + + $items = $Preferences[$typeName] + if (-not $items -or $items.Count -eq 0) { continue } + + # Group items by scope + $byScope = @{ Machine = @(); User = @() } + foreach ($item in $items) { + $scope = switch ($typeName) { + 'DriveMaps' { 'User' } + 'Printers' { 'User' } + 'Services' { 'Machine' } + 'NetworkShares' { 'Machine' } + 'LocalUsersAndGroups' { 'Machine' } + default { if ($item.Scope -eq 'User') { 'User' } else { 'Machine' } } + } + $byScope[$scope] += $item + } + + foreach ($scope in @('Machine', 'User')) { + $scopeItems = $byScope[$scope] + if ($scopeItems.Count -eq 0) { continue } + + # Generate XML + $xml = switch ($typeName) { + 'ScheduledTasks' { ConvertTo-ScheduledTaskXml -Tasks $scopeItems -Scope $scope } + 'DriveMaps' { ConvertTo-DriveMapXml -Drives $scopeItems } + 'EnvironmentVariables' { ConvertTo-EnvironmentVariableXml -Variables $scopeItems -Scope $scope } + 'Services' { ConvertTo-ServiceXml -Services $scopeItems } + 'Printers' { ConvertTo-PrinterXml -Printers $scopeItems } + 'Shortcuts' { ConvertTo-ShortcutXml -Shortcuts $scopeItems -Scope $scope } + 'Files' { ConvertTo-FileXml -Files $scopeItems -Scope $scope } + 'NetworkShares' { ConvertTo-NetworkShareXml -Shares $scopeItems } + 'RegistryItems' { ConvertTo-RegistryItemXml -Items $scopeItems -Scope $scope } + 'LocalUsersAndGroups' { ConvertTo-LocalGroupXml -Groups $scopeItems } + } + + # Write to SYSVOL + $prefDir = Join-Path $sysvolPath "$scope\Preferences\$($typeInfo.SysvolDir)" + if (-not (Test-Path $prefDir)) { + New-Item -ItemType Directory -Path $prefDir -Force | Out-Null + } + $xmlPath = Join-Path $prefDir $typeInfo.FileName + [System.IO.File]::WriteAllText($xmlPath, $xml, $utf8Bom) + Write-Host " Written: $scope\Preferences\$($typeInfo.SysvolDir)\$($typeInfo.FileName) ($($scopeItems.Count) item(s))" -ForegroundColor Green + + # Register CSE extension GUIDs + Add-GPOExtensionGuids -GPOName $GPOName -CseGuid $typeInfo.CseGuid -ToolGuid $Script:GppToolGuid -Scope $scope -Domain $Domain + } + } + +} + +function Compare-GPOPreferences { + <# + .SYNOPSIS + Compares desired Group Policy Preferences against what's currently + deployed in SYSVOL. Checks XML file existence and item presence by name. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [Parameter(Mandatory)] + [hashtable]$Preferences, + + [string]$Domain = (Get-ADDomain).DNSRoot + ) + + $sysvolPath = Get-GPOSysvolPath -GPOName $GPOName -Domain $Domain + $diffs = @() + + Write-Host " Comparing preferences..." -ForegroundColor Yellow + + foreach ($typeName in $Preferences.Keys) { + $typeInfo = $Script:GppTypeInfo[$typeName] + if (-not $typeInfo) { continue } + + $items = $Preferences[$typeName] + if (-not $items -or $items.Count -eq 0) { continue } + + # Group items by scope + $byScope = @{ Machine = @(); User = @() } + foreach ($item in $items) { + $scope = switch ($typeName) { + 'DriveMaps' { 'User' } + 'Printers' { 'User' } + 'Services' { 'Machine' } + 'NetworkShares' { 'Machine' } + 'LocalUsersAndGroups' { 'Machine' } + default { if ($item.Scope -eq 'User') { 'User' } else { 'Machine' } } + } + $byScope[$scope] += $item + } + + foreach ($scope in @('Machine', 'User')) { + $scopeItems = $byScope[$scope] + if ($scopeItems.Count -eq 0) { continue } + + $xmlPath = Join-Path $sysvolPath "$scope\Preferences\$($typeInfo.SysvolDir)\$($typeInfo.FileName)" + + if (-not (Test-Path $xmlPath)) { + Write-Host " [DRIFT] Missing: $scope\Preferences\$($typeInfo.SysvolDir)\$($typeInfo.FileName)" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'Preference' + PrefType = $typeName + Scope = $scope + Item = '(entire file)' + Status = 'Missing' + } + continue + } + + # Parse existing XML and check for each desired item + try { + [xml]$existingXml = Get-Content $xmlPath -Raw + + foreach ($item in $scopeItems) { + $itemName = $item.($typeInfo.NameKey) + + # DriveMaps use "Z:" format in XML but settings use "Z" + $searchName = $itemName + if ($typeName -eq 'DriveMaps') { + $searchName = "$($itemName.TrimEnd(':')):" + } + + $found = $existingXml.SelectNodes("//*[@name='$searchName']") + if (-not $found -or $found.Count -eq 0) { + Write-Host " [DRIFT] Missing $typeName item: $itemName" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'Preference' + PrefType = $typeName + Scope = $scope + Item = $itemName + Status = 'Missing item' + } + } else { + Write-Host " [OK] $typeName`: $itemName" -ForegroundColor Green + } + } + } catch { + Write-Host " [DRIFT] Cannot parse: $($typeInfo.FileName) -- $($_.Exception.Message)" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'Preference' + PrefType = $typeName + Scope = $scope + Item = '(parse error)' + Status = 'Invalid XML' + } + } + } + } + + if ($diffs.Count -eq 0) { + Write-Host " [OK] All preferences match desired state" -ForegroundColor Green + } else { + Write-Host " [DRIFT] $($diffs.Count) preference difference(s) found" -ForegroundColor Red + } + + return $diffs +} diff --git a/gpo/lib/GPOScripts.ps1 b/gpo/lib/GPOScripts.ps1 new file mode 100644 index 0000000..cd7d304 --- /dev/null +++ b/gpo/lib/GPOScripts.ps1 @@ -0,0 +1,271 @@ +# GPOScripts.ps1 +# Startup/shutdown/logon/logoff script deployment to SYSVOL. +# Depends on: GPOCore.ps1 (Get-GPOSysvolPath, Add-GPOExtensionGuids) + +function Set-GPOScripts { + <# + .SYNOPSIS + Deploys startup/shutdown/logon/logoff scripts to a GPO's SYSVOL path + and generates the corresponding psscripts.ini / scripts.ini files. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [Parameter(Mandatory)] + [hashtable]$Scripts, + + [Parameter(Mandatory)] + [string]$SourceDir, + + [string]$Domain = (Get-ADDomain).DNSRoot + ) + + $sysvolPath = Get-GPOSysvolPath -GPOName $GPOName -Domain $Domain + + # Map settings keys to SYSVOL paths and ini section names + $typeInfo = @{ + MachineStartup = @{ Scope = 'Machine'; SubDir = 'Startup'; Section = 'Startup' } + MachineShutdown = @{ Scope = 'Machine'; SubDir = 'Shutdown'; Section = 'Shutdown' } + UserLogon = @{ Scope = 'User'; SubDir = 'Logon'; Section = 'Logon' } + UserLogoff = @{ Scope = 'User'; SubDir = 'Logoff'; Section = 'Logoff' } + } + + # Script CSE GUID and tool extension GUIDs + $scriptCseGuid = '{42B5FAAE-6536-11D2-AE5A-0000F87571E3}' + $machineToolGuid = '{40B6664F-4972-11D1-A7CA-0000F87571E3}' + $userToolGuid = '{40B66650-4972-11D1-A7CA-0000F87571E3}' + + # Group work by scope (Machine / User) for ini file generation + $scopeWork = @{ + Machine = @{ PsSections = [ordered]@{}; CmdSections = [ordered]@{} } + User = @{ PsSections = [ordered]@{}; CmdSections = [ordered]@{} } + } + + foreach ($type in $Scripts.Keys) { + $info = $typeInfo[$type] + if (-not $info) { + Write-Host " [WARN] Unknown script type: $type" -ForegroundColor Yellow + continue + } + + $scope = $info.Scope + $section = $info.Section + $scriptDir = Join-Path $sysvolPath "$scope\Scripts\$($info.SubDir)" + + if (-not (Test-Path $scriptDir)) { + New-Item -ItemType Directory -Path $scriptDir -Force | Out-Null + } + + $psEntries = @() + $cmdEntries = @() + + foreach ($script in $Scripts[$type]) { + $sourcePath = Join-Path $SourceDir $script.Source + $fileName = Split-Path $script.Source -Leaf + $destPath = Join-Path $scriptDir $fileName + $params = if ($script.Parameters) { $script.Parameters } else { '' } + + Copy-Item -Path $sourcePath -Destination $destPath -Force + Write-Host " Copied: $fileName -> $scope\Scripts\$($info.SubDir)\" -ForegroundColor Green + + $entry = @{ CmdLine = $fileName; Parameters = $params } + if ($fileName -match '\.ps1$') { + $psEntries += $entry + } else { + $cmdEntries += $entry + } + } + + if ($psEntries.Count -gt 0) { + $scopeWork[$scope].PsSections[$section] = $psEntries + } + if ($cmdEntries.Count -gt 0) { + $scopeWork[$scope].CmdSections[$section] = $cmdEntries + } + } + + # Generate ini files per scope + foreach ($scope in @('Machine', 'User')) { + $work = $scopeWork[$scope] + $scriptsDir = Join-Path $sysvolPath "$scope\Scripts" + + # psscripts.ini -- PowerShell scripts + if ($work.PsSections.Count -gt 0) { + if (-not (Test-Path $scriptsDir)) { + New-Item -ItemType Directory -Path $scriptsDir -Force | Out-Null + } + + $sb = [System.Text.StringBuilder]::new() + foreach ($section in $work.PsSections.Keys) { + [void]$sb.AppendLine("[$section]") + $idx = 0 + foreach ($entry in $work.PsSections[$section]) { + [void]$sb.AppendLine("${idx}CmdLine=$($entry.CmdLine)") + [void]$sb.AppendLine("${idx}Parameters=$($entry.Parameters)") + $idx++ + } + [void]$sb.AppendLine('') + } + + $iniPath = Join-Path $scriptsDir 'psscripts.ini' + [System.IO.File]::WriteAllText($iniPath, $sb.ToString(), [System.Text.Encoding]::Unicode) + Write-Host " Written: $scope\Scripts\psscripts.ini" -ForegroundColor Green + } + + # scripts.ini -- non-PowerShell scripts (.bat, .cmd, .exe) + if ($work.CmdSections.Count -gt 0) { + if (-not (Test-Path $scriptsDir)) { + New-Item -ItemType Directory -Path $scriptsDir -Force | Out-Null + } + + $sb = [System.Text.StringBuilder]::new() + foreach ($section in $work.CmdSections.Keys) { + [void]$sb.AppendLine("[$section]") + $idx = 0 + foreach ($entry in $work.CmdSections[$section]) { + [void]$sb.AppendLine("${idx}CmdLine=$($entry.CmdLine)") + [void]$sb.AppendLine("${idx}Parameters=$($entry.Parameters)") + $idx++ + } + [void]$sb.AppendLine('') + } + + $iniPath = Join-Path $scriptsDir 'scripts.ini' + [System.IO.File]::WriteAllText($iniPath, $sb.ToString(), [System.Text.Encoding]::Unicode) + Write-Host " Written: $scope\Scripts\scripts.ini" -ForegroundColor Green + } + + # Update CSE extension GUIDs in AD + if ($work.PsSections.Count -gt 0 -or $work.CmdSections.Count -gt 0) { + $toolGuid = if ($scope -eq 'Machine') { $machineToolGuid } else { $userToolGuid } + Add-GPOExtensionGuids -GPOName $GPOName -CseGuid $scriptCseGuid -ToolGuid $toolGuid -Scope $scope -Domain $Domain + } + } + +} + +function Compare-GPOScripts { + <# + .SYNOPSIS + Compares desired scripts against what's currently deployed in a GPO's + SYSVOL path. Returns diff objects for missing or changed scripts. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [Parameter(Mandatory)] + [hashtable]$Scripts, + + [Parameter(Mandatory)] + [string]$SourceDir, + + [string]$Domain = (Get-ADDomain).DNSRoot + ) + + $sysvolPath = Get-GPOSysvolPath -GPOName $GPOName -Domain $Domain + + $typeInfo = @{ + MachineStartup = @{ Scope = 'Machine'; SubDir = 'Startup' } + MachineShutdown = @{ Scope = 'Machine'; SubDir = 'Shutdown' } + UserLogon = @{ Scope = 'User'; SubDir = 'Logon' } + UserLogoff = @{ Scope = 'User'; SubDir = 'Logoff' } + } + + $diffs = @() + + Write-Host " Comparing scripts..." -ForegroundColor Yellow + + foreach ($type in $Scripts.Keys) { + $info = $typeInfo[$type] + if (-not $info) { continue } + + $scriptDir = Join-Path $sysvolPath "$($info.Scope)\Scripts\$($info.SubDir)" + + foreach ($script in $Scripts[$type]) { + $fileName = Split-Path $script.Source -Leaf + $sourcePath = Join-Path $SourceDir $script.Source + $destPath = Join-Path $scriptDir $fileName + + if (-not (Test-Path $destPath)) { + Write-Host " [DRIFT] Missing: $fileName in $($info.Scope)\Scripts\$($info.SubDir)\" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'Script' + ScriptType = $type + FileName = $fileName + Status = 'Missing' + } + continue + } + + # Compare file content by hash + $sourceHash = (Get-FileHash -Path $sourcePath -Algorithm SHA256).Hash + $destHash = (Get-FileHash -Path $destPath -Algorithm SHA256).Hash + + if ($sourceHash -ne $destHash) { + Write-Host " [DRIFT] Changed: $fileName in $($info.Scope)\Scripts\$($info.SubDir)\" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'Script' + ScriptType = $type + FileName = $fileName + Status = 'Content changed' + } + } else { + Write-Host " [OK] $fileName" -ForegroundColor Green + } + } + } + + # Check psscripts.ini / scripts.ini existence per scope + $scopeHasPs = @{ Machine = $false; User = $false } + $scopeHasCmd = @{ Machine = $false; User = $false } + + foreach ($type in $Scripts.Keys) { + $info = $typeInfo[$type] + if (-not $info) { continue } + foreach ($script in $Scripts[$type]) { + $fileName = Split-Path $script.Source -Leaf + if ($fileName -match '\.ps1$') { $scopeHasPs[$info.Scope] = $true } + else { $scopeHasCmd[$info.Scope] = $true } + } + } + + foreach ($scope in @('Machine', 'User')) { + $scriptsDir = Join-Path $sysvolPath "$scope\Scripts" + + if ($scopeHasPs[$scope]) { + $iniPath = Join-Path $scriptsDir 'psscripts.ini' + if (-not (Test-Path $iniPath)) { + Write-Host " [DRIFT] Missing: $scope\Scripts\psscripts.ini" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'ScriptIni' + Scope = $scope + FileName = 'psscripts.ini' + Status = 'Missing' + } + } + } + + if ($scopeHasCmd[$scope]) { + $iniPath = Join-Path $scriptsDir 'scripts.ini' + if (-not (Test-Path $iniPath)) { + Write-Host " [DRIFT] Missing: $scope\Scripts\scripts.ini" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'ScriptIni' + Scope = $scope + FileName = 'scripts.ini' + Status = 'Missing' + } + } + } + } + + if ($diffs.Count -eq 0) { + Write-Host " [OK] All scripts match desired state" -ForegroundColor Green + } else { + Write-Host " [DRIFT] $($diffs.Count) script difference(s) found" -ForegroundColor Red + } + + return $diffs +} diff --git a/gpo/lib/GPOWdac.ps1 b/gpo/lib/GPOWdac.ps1 new file mode 100644 index 0000000..999d1ab --- /dev/null +++ b/gpo/lib/GPOWdac.ps1 @@ -0,0 +1,181 @@ +# GPOWdac.ps1 +# Lightweight WDAC (Windows Defender Application Control) policy deployment via GPO. +# Copies .p7b policy to SYSVOL and sets the registry pointer. +# Depends on: GPOCore.ps1 (Get-GPOSysvolPath) + +function Set-GPOWdacPolicy { + <# + .SYNOPSIS + Deploys a WDAC policy file to a GPO. + If source is .xml, converts to .p7b with ConvertFrom-CIPolicy. + Copies .p7b to SYSVOL and sets the DeployConfigCIPolicy registry key. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [Parameter(Mandatory)] + [hashtable]$WDACPolicy, + + [Parameter(Mandatory)] + [string]$SourceDir, + + [string]$Domain = (Get-ADDomain).DNSRoot + ) + + Write-Host " Applying WDAC policy..." -ForegroundColor Yellow + + $policyFile = $WDACPolicy.PolicyFile + $sourcePath = Join-Path $SourceDir $policyFile + + if (-not (Test-Path $sourcePath)) { + throw "WDAC policy file not found: $sourcePath" + } + + $sysvolPath = Get-GPOSysvolPath -GPOName $GPOName -Domain $Domain + $deviceGuardDir = Join-Path $sysvolPath 'Machine\Microsoft\Windows\DeviceGuard' + + if (-not (Test-Path $deviceGuardDir)) { + New-Item -ItemType Directory -Path $deviceGuardDir -Force | Out-Null + } + + $extension = [System.IO.Path]::GetExtension($sourcePath).ToLower() + + if ($extension -eq '.xml') { + # Convert XML to .p7b + $destPath = Join-Path $deviceGuardDir 'SIPolicy.p7b' + ConvertFrom-CIPolicy -XmlFilePath $sourcePath -BinaryFilePath $destPath | Out-Null + Write-Host " Converted $policyFile -> SIPolicy.p7b" -ForegroundColor Green + } elseif ($extension -eq '.p7b') { + # Copy directly + $destPath = Join-Path $deviceGuardDir 'SIPolicy.p7b' + Copy-Item -Path $sourcePath -Destination $destPath -Force + Write-Host " Copied $policyFile -> SIPolicy.p7b" -ForegroundColor Green + } else { + throw "WDAC policy file must be .xml or .p7b, got: $extension" + } + + # Set registry key to enable WDAC via GPO + $regKey = 'HKLM\SOFTWARE\Policies\Microsoft\Windows\DeviceGuard' + Set-GPRegistryValue -Name $GPOName -Key $regKey -ValueName 'DeployConfigCIPolicy' ` + -Type DWord -Value 1 -Domain $Domain | Out-Null + + Write-Host " [OK] WDAC policy deployed to $GPOName" -ForegroundColor Green +} + +function Compare-GPOWdacPolicy { + <# + .SYNOPSIS + Compares desired WDAC policy against current GPO state. + Checks registry key and .p7b file existence/hash. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [Parameter(Mandatory)] + [hashtable]$WDACPolicy, + + [Parameter(Mandatory)] + [string]$SourceDir, + + [string]$Domain = (Get-ADDomain).DNSRoot + ) + + $diffs = @() + + Write-Host " Comparing WDAC policy..." -ForegroundColor Yellow + + # Check registry key + $regKey = 'HKLM\SOFTWARE\Policies\Microsoft\Windows\DeviceGuard' + $currentRegValue = $null + try { + $regResult = Get-GPRegistryValue -Name $GPOName -Key $regKey -ValueName 'DeployConfigCIPolicy' ` + -Domain $Domain -ErrorAction Stop + $currentRegValue = $regResult.Value + } catch { + $currentRegValue = $null + } + + if ($currentRegValue -ne 1) { + $display = if ($null -eq $currentRegValue) { '(not set)' } else { $currentRegValue } + Write-Host " [DRIFT] DeployConfigCIPolicy: $display -> 1" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'WDAC' + Setting = 'DeployConfigCIPolicy' + Status = "Registry: $display -> 1" + } + } else { + Write-Host " [OK] DeployConfigCIPolicy = 1" -ForegroundColor Green + } + + # Check .p7b file exists in SYSVOL + $sysvolPath = Get-GPOSysvolPath -GPOName $GPOName -Domain $Domain + $p7bPath = Join-Path $sysvolPath 'Machine\Microsoft\Windows\DeviceGuard\SIPolicy.p7b' + + if (-not (Test-Path $p7bPath)) { + Write-Host " [DRIFT] Missing: SIPolicy.p7b" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'WDAC' + Setting = 'SIPolicy.p7b' + Status = 'Missing' + } + } else { + # Compare file hashes if source exists + $policyFile = $WDACPolicy.PolicyFile + $sourcePath = Join-Path $SourceDir $policyFile + + if (Test-Path $sourcePath) { + $sourceExt = [System.IO.Path]::GetExtension($sourcePath).ToLower() + + if ($sourceExt -eq '.p7b') { + $sourceHash = (Get-FileHash -Path $sourcePath -Algorithm SHA256).Hash + $deployedHash = (Get-FileHash -Path $p7bPath -Algorithm SHA256).Hash + + if ($sourceHash -ne $deployedHash) { + Write-Host " [DRIFT] SIPolicy.p7b hash mismatch (source updated)" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'WDAC' + Setting = 'SIPolicy.p7b' + Status = 'Hash mismatch' + } + } else { + Write-Host " [OK] SIPolicy.p7b hash matches" -ForegroundColor Green + } + } else { + # Source is .xml -- convert to temp .p7b and compare + $tempP7b = [System.IO.Path]::GetTempFileName() + try { + ConvertFrom-CIPolicy -XmlFilePath $sourcePath -BinaryFilePath $tempP7b | Out-Null + $sourceHash = (Get-FileHash -Path $tempP7b -Algorithm SHA256).Hash + $deployedHash = (Get-FileHash -Path $p7bPath -Algorithm SHA256).Hash + + if ($sourceHash -ne $deployedHash) { + Write-Host " [DRIFT] SIPolicy.p7b hash mismatch (source XML updated)" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'WDAC' + Setting = 'SIPolicy.p7b' + Status = 'Hash mismatch' + } + } else { + Write-Host " [OK] SIPolicy.p7b matches source XML" -ForegroundColor Green + } + } catch { + Write-Host " [WARN] Cannot convert source XML for comparison: $($_.Exception.Message)" -ForegroundColor Yellow + } finally { + Remove-Item $tempP7b -Force -ErrorAction SilentlyContinue + } + } + } else { + Write-Host " [OK] SIPolicy.p7b exists (source file not found for hash comparison)" -ForegroundColor Green + } + } + + if ($diffs.Count -eq 0) { + Write-Host " [OK] WDAC policy matches desired state" -ForegroundColor Green + } else { + Write-Host " [DRIFT] $($diffs.Count) WDAC difference(s) found" -ForegroundColor Red + } + + return $diffs +} diff --git a/gpo/lib/GPOWmiFilter.ps1 b/gpo/lib/GPOWmiFilter.ps1 new file mode 100644 index 0000000..24e2036 --- /dev/null +++ b/gpo/lib/GPOWmiFilter.ps1 @@ -0,0 +1,190 @@ +# GPOWmiFilter.ps1 +# WMI filter creation and GPO linking. +# No dependencies on GPOCore.ps1 (uses only AD cmdlets directly). + +function Ensure-GPOWmiFilter { + <# + .SYNOPSIS + Creates a WMI filter AD object if missing, then links it to the GPO. + Idempotent: skips creation if a filter with the same name exists, + updates the query or description if they have changed. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [Parameter(Mandatory)] + [string]$FilterName, + + [string]$Description = '', + + [Parameter(Mandatory)] + [string]$Query, + + [string]$Domain = (Get-ADDomain).DNSRoot + ) + + $domainDN = (Get-ADDomain -Server $Domain).DistinguishedName + $wmiContainer = "CN=SOM,CN=WMIPolicy,CN=System,$domainDN" + + # Format the WQL query for msWMI-Parm2 + # Format: 1;3;10;;WQL;root\CIMv2;; + # 10 = length of 'root\CIMv2' + $queryLen = $Query.Length + $parm2 = "1;3;10;$queryLen;WQL;root\CIMv2;$Query;" + + # Search for existing filter by name + $existing = Get-ADObject -SearchBase $wmiContainer -Filter { objectClass -eq 'msWMI-Som' } ` + -Properties 'msWMI-Name', 'msWMI-Parm1', 'msWMI-Parm2', 'msWMI-ID' | + Where-Object { $_.'msWMI-Name' -eq $FilterName } + + if ($existing) { + $filterGuid = $existing.'msWMI-ID' + Write-Host " [OK] WMI filter exists: $FilterName ($filterGuid)" -ForegroundColor Green + + # Check if query or description need updating + $updates = @{} + if ($existing.'msWMI-Parm2' -ne $parm2) { + $updates['msWMI-Parm2'] = $parm2 + } + if ($existing.'msWMI-Parm1' -ne $Description) { + $updates['msWMI-Parm1'] = $Description + } + + if ($updates.Count -gt 0) { + $timestamp = (Get-Date).ToUniversalTime().ToString('yyyyMMddHHmmss.ffffff-000') + $updates['msWMI-ChangeDate'] = $timestamp + Set-ADObject $existing.DistinguishedName -Replace $updates + Write-Host " [UPDATED] WMI filter updated: $FilterName" -ForegroundColor Yellow + } + } else { + # Create new WMI filter + $filterGuid = "{$([Guid]::NewGuid().ToString().ToUpper())}" + $timestamp = (Get-Date).ToUniversalTime().ToString('yyyyMMddHHmmss.ffffff-000') + $author = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name + + $attributes = @{ + 'msWMI-Name' = $FilterName + 'msWMI-Parm1' = $Description + 'msWMI-Parm2' = $parm2 + 'msWMI-ID' = $filterGuid + 'msWMI-Author' = $author + 'msWMI-CreationDate' = $timestamp + 'msWMI-ChangeDate' = $timestamp + } + + New-ADObject -Name $filterGuid -Type 'msWMI-Som' -Path $wmiContainer ` + -OtherAttributes $attributes + Write-Host " [CREATED] WMI filter: $FilterName ($filterGuid)" -ForegroundColor Green + } + + # Link the WMI filter to the GPO + $gpo = Get-GPO -Name $GPOName -Domain $Domain -ErrorAction Stop + $gpoDN = "CN={$($gpo.Id)},CN=Policies,CN=System,$domainDN" + $linkValue = "[$Domain;$filterGuid;0]" + + $currentLink = (Get-ADObject $gpoDN -Properties gPCWQLFilter).gPCWQLFilter + if ($currentLink -eq $linkValue) { + Write-Host " [OK] WMI filter linked to GPO: $GPOName" -ForegroundColor Green + } else { + Set-ADObject $gpoDN -Replace @{ gPCWQLFilter = $linkValue } + Write-Host " [LINKED] WMI filter '$FilterName' -> GPO: $GPOName" -ForegroundColor Yellow + } +} + +function Compare-GPOWmiFilter { + <# + .SYNOPSIS + Checks if a WMI filter exists with the correct query and is linked + to the GPO. Returns diff objects for any discrepancies. + #> + param( + [Parameter(Mandatory)] + [string]$GPOName, + + [Parameter(Mandatory)] + [string]$FilterName, + + [string]$Description = '', + + [Parameter(Mandatory)] + [string]$Query, + + [string]$Domain = (Get-ADDomain).DNSRoot + ) + + $diffs = @() + $domainDN = (Get-ADDomain -Server $Domain).DistinguishedName + $wmiContainer = "CN=SOM,CN=WMIPolicy,CN=System,$domainDN" + + $queryLen = $Query.Length + $parm2 = "1;3;10;$queryLen;WQL;root\CIMv2;$Query;" + + Write-Host " Checking WMI filter..." -ForegroundColor Yellow + + # Search for existing filter by name + $existing = Get-ADObject -SearchBase $wmiContainer -Filter { objectClass -eq 'msWMI-Som' } ` + -Properties 'msWMI-Name', 'msWMI-Parm1', 'msWMI-Parm2', 'msWMI-ID' | + Where-Object { $_.'msWMI-Name' -eq $FilterName } + + if (-not $existing) { + Write-Host " [DRIFT] WMI filter not found: $FilterName" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'WMIFilter' + Filter = $FilterName + Status = 'Missing' + } + return $diffs + } + + $filterGuid = $existing.'msWMI-ID' + Write-Host " [OK] WMI filter exists: $FilterName" -ForegroundColor Green + + # Check query + if ($existing.'msWMI-Parm2' -ne $parm2) { + Write-Host " [DRIFT] WMI filter query differs" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'WMIFilterQuery' + Filter = $FilterName + Current = $existing.'msWMI-Parm2' + Desired = $parm2 + } + } + + # Check description + if ($existing.'msWMI-Parm1' -ne $Description) { + Write-Host " [DRIFT] WMI filter description differs" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'WMIFilterDescription' + Filter = $FilterName + Current = $existing.'msWMI-Parm1' + Desired = $Description + } + } + + # Check link to GPO + $gpo = Get-GPO -Name $GPOName -Domain $Domain -ErrorAction Stop + $gpoDN = "CN={$($gpo.Id)},CN=Policies,CN=System,$domainDN" + $linkValue = "[$Domain;$filterGuid;0]" + $currentLink = (Get-ADObject $gpoDN -Properties gPCWQLFilter).gPCWQLFilter + + if ($currentLink -eq $linkValue) { + Write-Host " [OK] WMI filter linked to GPO" -ForegroundColor Green + } else { + $currentDisplay = if ($currentLink) { $currentLink } else { '(none)' } + Write-Host " [DRIFT] WMI filter not linked to GPO: $GPOName" -ForegroundColor Red + $diffs += [PSCustomObject]@{ + Type = 'WMIFilterLink' + GPO = $GPOName + Filter = $FilterName + Current = $currentDisplay + Desired = $linkValue + } + } + + if ($diffs.Count -eq 0) { + Write-Host " [OK] WMI filter matches desired state" -ForegroundColor Green + } + + return $diffs +} diff --git a/gpo/servers-01/README.md b/gpo/servers-01/README.md new file mode 100644 index 0000000..f1acfa7 --- /dev/null +++ b/gpo/servers-01/README.md @@ -0,0 +1,115 @@ +# Servers-01 GPO + +Server hardening policy for domain-joined servers in the ExampleServers OU. + +## Linked To + +`OU=ExampleServers,DC=example,DC=internal` + +## Design + +Full audit, logging, and hardening baseline for servers. Compared to Workstations-01: + +- **Full audit coverage** -- every category audits both success and failure, including process tracking and DS access +- **PowerShell transcription** -- complete session recording to `C:\PSlogs\Transcripts` +- **Module logging** -- all PowerShell modules logged +- **Command line in process creation** -- Event ID 4688 includes full command line +- **Larger event logs** -- 64/256 MB (matches AdminWorkstations-01) +- **Weekly updates** -- Sunday 3 AM instead of daily (minimize reboot disruption for services) + +Compared to AdminWorkstations-01, this GPO does **not** include: + +- `LocalAccountTokenFilterPolicy` (servers are not admin workstations) +- Defender exclusions for JetBrains (servers are not dev machines) + +## WMI Filter + +| Property | Value | +|---|---| +| Name | Member Servers Only | +| Query | `SELECT * FROM Win32_OperatingSystem WHERE ProductType = 3` | + +Defense-in-depth: ensures this GPO only applies to member servers (ProductType 3), not domain controllers (ProductType 2) or workstations (ProductType 1). + +## Restricted Groups + +| Local Group | Enforced Members | +|---|---| +| BUILTIN\Administrators | Domain Admins, MasterAdmins | + +Any locally-added administrator accounts are removed on next GPO refresh. + +## Security Policy Settings (GptTmpl.inf) + +### System Access + +| Setting | Value | Effect | +|---|---|---| +| EnableGuestAccount | 0 | Local guest account disabled | + +### Event Audit + +| Setting | Value | Effect | +|---|---|---| +| AuditSystemEvents | 3 | Success + Failure | +| AuditLogonEvents | 3 | Success + Failure | +| AuditObjectAccess | 3 | Success + Failure | +| AuditPrivilegeUse | 3 | Success + Failure | +| AuditPolicyChange | 3 | Success + Failure | +| AuditAccountManage | 3 | Success + Failure | +| AuditProcessTracking | 3 | Success + Failure | +| AuditDSAccess | 3 | Success + Failure | +| AuditAccountLogon | 3 | Success + Failure | + +### Registry Values (Security Options) + +| Setting | Value | Effect | +|---|---|---| +| InactivityTimeoutSecs | 900 | Auto-lock after 15 minutes | +| DontDisplayLastUserName | 1 | Don't show last user at login screen | +| DisableCAD | 0 | Require Ctrl+Alt+Del | + +## Registry Settings (Administrative Templates) + +### Autorun / Autoplay + +| Key | ValueName | Value | Effect | +|---|---|---|---| +| Policies\Explorer | NoDriveTypeAutoRun | 255 | Disable autorun on all drives | +| Policies\Explorer | NoAutorun | 1 | Disable autoplay | + +### Windows Update + +| Key | ValueName | Value | Effect | +|---|---|---|---| +| WindowsUpdate\AU | NoAutoUpdate | 0 | Enable automatic updates | +| WindowsUpdate\AU | AUOptions | 4 | Auto download + schedule install | +| WindowsUpdate\AU | ScheduledInstallDay | 1 | Sunday | +| WindowsUpdate\AU | ScheduledInstallTime | 3 | 3:00 AM | + +### Logging & Auditing + +| Key | ValueName | Value | Effect | +|---|---|---|---| +| PowerShell\ScriptBlockLogging | EnableScriptBlockLogging | 1 | Log all script blocks | +| PowerShell\Transcription | EnableTranscripting | 1 | Record full PS sessions | +| PowerShell\Transcription | OutputDirectory | C:\PSlogs\Transcripts | Transcript save location | +| PowerShell\Transcription | EnableInvocationHeader | 1 | Timestamp per command | +| PowerShell\ModuleLogging | EnableModuleLogging | 1 | Log all module activity | +| PowerShell\ModuleLogging\ModuleNames | * | * | All modules | +| System\Audit | ProcessCreationIncludeCmdLine_Enabled | 1 | Command line in Event 4688 | + +### Event Log Sizes + +| Log | Size | vs. Workstations-01 | +|---|---|---| +| Application | 64 MB | 2x | +| Security | 256 MB | ~1.3x | +| System | 64 MB | 2x | +| PowerShell | 64 MB | new | + +### Remote Desktop + +| Key | ValueName | Value | Effect | +|---|---|---|---| +| Terminal Services | UserAuthentication | 1 | Require NLA | diff --git a/gpo/servers-01/settings.ps1 b/gpo/servers-01/settings.ps1 new file mode 100644 index 0000000..1e4767a --- /dev/null +++ b/gpo/servers-01/settings.ps1 @@ -0,0 +1,357 @@ +# Servers-01 -- Settings Declaration +# Linked to: OU=ExampleServers,DC=example,DC=internal +# +# Server hardening policy. Full audit coverage, PowerShell transcription, +# and operational baselines for domain-joined servers in the ExampleServers OU. +# All settings are Computer Configuration (HKLM). + +@{ + GPOName = 'Servers-01' + Description = 'Server hardening -- full audit, transcription, and operational baseline' + + DisableUserConfiguration = $true + + LinkTo = 'OU=ExampleServers,DC=example,DC=internal' + + # Defense-in-depth: only apply to member servers (ProductType 3). + # ProductType 2 = domain controllers (have their own OU/GPO). + WMIFilter = @{ + Name = 'Member Servers Only' + Description = 'Targets member server operating systems (ProductType = 3)' + Query = "SELECT * FROM Win32_OperatingSystem WHERE ProductType = 3" + } + + # Lock down local Administrators to Domain Admins + MasterAdmins only. + # Any unauthorized additions are removed on next GPO refresh. + RestrictedGroups = @{ + 'BUILTIN\Administrators' = @{ + Members = @('EXAMPLE\Domain Admins', 'EXAMPLE\MasterAdmins') + } + } + + SecurityPolicy = @{ + + 'System Access' = [ordered]@{ + EnableGuestAccount = 0 + } + + 'Event Audit' = [ordered]@{ + # Full audit -- servers are high-value targets, log everything + AuditSystemEvents = 3 # Success + Failure + AuditLogonEvents = 3 # Success + Failure + AuditObjectAccess = 3 # Success + Failure + AuditPrivilegeUse = 3 # Success + Failure + AuditPolicyChange = 3 # Success + Failure + AuditAccountManage = 3 # Success + Failure + AuditProcessTracking = 3 # Success + Failure + AuditDSAccess = 3 # Success + Failure + AuditAccountLogon = 3 # Success + Failure + } + + 'Registry Values' = [ordered]@{ + # Interactive logon: Machine inactivity limit -- 15 min (servers may have long console sessions) + 'MACHINE\Software\Microsoft\Windows\CurrentVersion\Policies\System\InactivityTimeoutSecs' = '4,900' + + # Interactive logon: Don't display last signed-in user + 'MACHINE\Software\Microsoft\Windows\CurrentVersion\Policies\System\DontDisplayLastUserName' = '4,1' + + # Interactive logon: Require CTRL+ALT+DEL + 'MACHINE\Software\Microsoft\Windows NT\CurrentVersion\Winlogon\DisableCAD' = '4,0' + } + } + + # ================================================================= + # Windows Firewall -- default-deny inbound, allow management traffic + # ================================================================= + + FirewallProfiles = @{ + Domain = @{ Enabled = $true; DefaultInboundAction = 'Block'; DefaultOutboundAction = 'Allow' } + Private = @{ Enabled = $true; DefaultInboundAction = 'Block'; DefaultOutboundAction = 'Allow' } + Public = @{ Enabled = $true; DefaultInboundAction = 'Block'; DefaultOutboundAction = 'Allow' } + } + + FirewallRules = @( + # WinRM -- remote management (Invoke-Command, Enter-PSSession) + @{ + DisplayName = 'Allow WinRM HTTP (Inbound)' + Direction = 'Inbound' + Action = 'Allow' + Protocol = 'TCP' + LocalPort = '5985' + Profile = 'Domain' + Description = 'Windows Remote Management (HTTP) for domain management' + } + + # RDP -- remote console access (NLA required via registry setting above) + @{ + DisplayName = 'Allow RDP (Inbound)' + Direction = 'Inbound' + Action = 'Allow' + Protocol = 'TCP' + LocalPort = '3389' + Profile = 'Domain' + Description = 'Remote Desktop Protocol for domain-authenticated sessions' + } + + # ICMP Echo -- network troubleshooting and monitoring + @{ + DisplayName = 'Allow ICMP Echo Request (Inbound)' + Direction = 'Inbound' + Action = 'Allow' + Protocol = 'ICMPv4' + Profile = 'Domain' + Description = 'ICMP echo for ping-based health monitoring' + } + + # SMB -- file sharing and administrative shares (C$, ADMIN$) + @{ + DisplayName = 'Allow SMB (Inbound)' + Direction = 'Inbound' + Action = 'Allow' + Protocol = 'TCP' + LocalPort = '445' + Profile = 'Domain' + Description = 'Server Message Block for file sharing and admin shares' + } + ) + + # ================================================================= + # GPP Local Groups -- additive membership (does not replace full list) + # Use RestrictedGroups above for Administrators (exact membership). + # Use GPP Local Groups here for groups where we add members without + # wiping existing ones. + # ================================================================= + + Preferences = @{ + LocalUsersAndGroups = @( + @{ + GroupName = 'Remote Desktop Users' + Action = 'Update' + DeleteAllUsers = $false + DeleteAllGroups = $false + Members = @( + @{ Name = 'EXAMPLE\MasterAdmins'; Action = 'ADD' } + ) + } + ) + } + + # ================================================================= + # Advanced Audit Policy -- granular subcategory-level auditing + # Overrides the legacy Event Audit above with 53 subcategories. + # When both are present, Advanced Audit takes precedence on clients. + # ================================================================= + + AdvancedAuditPolicy = @{ + # System + 'Security State Change' = 'Success and Failure' + 'Security System Extension' = 'Success and Failure' + 'System Integrity' = 'Success and Failure' + + # Logon/Logoff + 'Logon' = 'Success and Failure' + 'Logoff' = 'Success' + 'Account Lockout' = 'Success' + 'Special Logon' = 'Success and Failure' + 'Other Logon/Logoff Events' = 'Success and Failure' + 'Group Membership' = 'Success' + + # Object Access + 'File System' = 'Success and Failure' + 'Registry' = 'Success and Failure' + 'File Share' = 'Success and Failure' + 'Detailed File Share' = 'Failure' + 'SAM' = 'Success and Failure' + 'Removable Storage' = 'Success and Failure' + 'Filtering Platform Connection' = 'Failure' + + # Privilege Use + 'Sensitive Privilege Use' = 'Success and Failure' + + # Detailed Tracking + 'Process Creation' = 'Success and Failure' + 'Process Termination' = 'Success' + 'Plug and Play Events' = 'Success' + + # Policy Change + 'Audit Policy Change' = 'Success and Failure' + 'Authentication Policy Change' = 'Success' + 'MPSSVC Rule-Level Policy Change' = 'Success and Failure' + + # Account Management + 'User Account Management' = 'Success and Failure' + 'Computer Account Management' = 'Success and Failure' + 'Security Group Management' = 'Success and Failure' + 'Other Account Management Events' = 'Success and Failure' + + # Account Logon + 'Credential Validation' = 'Success and Failure' + 'Kerberos Authentication Service' = 'Success and Failure' + 'Kerberos Service Ticket Operations' = 'Success and Failure' + } + + RegistrySettings = @( + + # ============================================================= + # Autorun / Autoplay + # ============================================================= + + # Disable autorun on all drive types (bitmask 0xFF = all) + @{ + Key = 'HKLM\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer' + ValueName = 'NoDriveTypeAutoRun' + Type = 'DWord' + Value = 255 + } + + # Disable autoplay entirely + @{ + Key = 'HKLM\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer' + ValueName = 'NoAutorun' + Type = 'DWord' + Value = 1 + } + + # ============================================================= + # Windows Update -- weekly Sunday 3 AM + # ============================================================= + + # Enable automatic updates + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\WindowsUpdate\AU' + ValueName = 'NoAutoUpdate' + Type = 'DWord' + Value = 0 + } + + # Auto download and schedule install + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\WindowsUpdate\AU' + ValueName = 'AUOptions' + Type = 'DWord' + Value = 4 + } + + # Schedule install day (0=Every day, 1=Sunday ... 7=Saturday) + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\WindowsUpdate\AU' + ValueName = 'ScheduledInstallDay' + Type = 'DWord' + Value = 1 + } + + # Schedule install time (0-23, 3 = 3:00 AM) + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\WindowsUpdate\AU' + ValueName = 'ScheduledInstallTime' + Type = 'DWord' + Value = 3 + } + + # ============================================================= + # Logging & Auditing -- full forensic capability + # ============================================================= + + # PowerShell script block logging + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging' + ValueName = 'EnableScriptBlockLogging' + Type = 'DWord' + Value = 1 + } + + # PowerShell transcription -- full session recording + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\PowerShell\Transcription' + ValueName = 'EnableTranscripting' + Type = 'DWord' + Value = 1 + } + + # Transcription output directory + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\PowerShell\Transcription' + ValueName = 'OutputDirectory' + Type = 'String' + Value = 'C:\PSlogs\Transcripts' + } + + # Include invocation headers in transcripts (timestamps per command) + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\PowerShell\Transcription' + ValueName = 'EnableInvocationHeader' + Type = 'DWord' + Value = 1 + } + + # PowerShell module logging -- log all module activity + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\PowerShell\ModuleLogging' + ValueName = 'EnableModuleLogging' + Type = 'DWord' + Value = 1 + } + + # Log all modules (wildcard) + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\PowerShell\ModuleLogging\ModuleNames' + ValueName = '*' + Type = 'String' + Value = '*' + } + + # Command line in process creation events (Event ID 4688) + @{ + Key = 'HKLM\Software\Microsoft\Windows\CurrentVersion\Policies\System\Audit' + ValueName = 'ProcessCreationIncludeCmdLine_Enabled' + Type = 'DWord' + Value = 1 + } + + # Event log sizes -- match AdminWorkstations (servers generate heavy event volume) + + # Application log: 64 MB + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\EventLog\Application' + ValueName = 'MaxSize' + Type = 'DWord' + Value = 65536 + } + + # Security log: 256 MB + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\EventLog\Security' + ValueName = 'MaxSize' + Type = 'DWord' + Value = 262144 + } + + # System log: 64 MB + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\EventLog\System' + ValueName = 'MaxSize' + Type = 'DWord' + Value = 65536 + } + + # PowerShell Operational log: 64 MB + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\EventLog\Windows PowerShell' + ValueName = 'MaxSize' + Type = 'DWord' + Value = 65536 + } + + # ============================================================= + # Remote Desktop + # ============================================================= + + # Require NLA for RDP + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows NT\Terminal Services' + ValueName = 'UserAuthentication' + Type = 'DWord' + Value = 1 + } + ) +} diff --git a/gpo/users-01/README.md b/gpo/users-01/README.md new file mode 100644 index 0000000..1ff6564 --- /dev/null +++ b/gpo/users-01/README.md @@ -0,0 +1,38 @@ +# Users-01 GPO + +**GUID:** Auto-created on first `Apply-GPOBaseline.ps1` run +**Linked to:** `OU=ExampleUsers,DC=example,DC=internal` +**Scope:** User Configuration (HKCU) -- Administrative Templates only + +This GPO applies to all user accounts in the ExampleUsers OU. Settings follow the user to any domain-joined machine they log into. + +## Settings + +### Desktop Hardening + +| Setting | Value | Effect | +|---|---|---| +| DisableRegistryTools | 1 | Blocks regedit.exe | +| DisableCMD | 2 | Blocks cmd.exe, allows batch files | +| NoRun | 1 | Removes Run from Start Menu | +| NoChangingWallPaper | 1 | Prevents changing desktop wallpaper | +| NoAddRemovePrograms | 1 | Hides Programs & Features in Control Panel | +| NoAddPrinter | 1 | Prevents adding printers | + +### UX Standardization + +| Setting | Value | Effect | +|---|---|---| +| Wallpaper | `C:\Windows\Web\Wallpaper\Windows\img0.jpg` | Default Windows wallpaper (replace with corporate UNC path when ready) | +| WallpaperStyle | 10 | Fill mode | +| SearchboxTaskbarMode | 0 | Hides Search box on taskbar | +| ShowTaskViewButton | 0 | Hides Task View button | +| TurnOffWindowsCopilot | 1 | Disables Windows Copilot | +| TaskbarDa | 0 | Hides Widgets | + +## Notes + +- No SecurityPolicy (GptTmpl.inf) settings -- user rights, account policies, and audit settings are Computer Configuration only +- All 12 settings are registry-based, applied via `Set-GPRegistryValue` +- Wallpaper currently points to the built-in Windows image; replace with a UNC path (e.g., `\\example.internal\NETLOGON\wallpaper.jpg`) when a corporate wallpaper is ready +- Taskbar settings (Widgets, Copilot) are Windows 11 / Server 2025 specific -- no-ops on older OS diff --git a/gpo/users-01/settings.ps1 b/gpo/users-01/settings.ps1 new file mode 100644 index 0000000..486e908 --- /dev/null +++ b/gpo/users-01/settings.ps1 @@ -0,0 +1,133 @@ +# Users-01 -- Settings Declaration +# Linked to: OU=ExampleUsers,DC=example,DC=internal +# +# This GPO targets user configuration for the ExampleUsers OU. +# All settings are User Configuration (HKCU) -- Administrative Templates. + +@{ + GPOName = 'Users-01' + Description = 'Standard user desktop hardening and UX standardization' + + DisableComputerConfiguration = $true + + LinkTo = 'OU=ExampleUsers,DC=example,DC=internal' + + # Deny Apply for admin groups -- DelegatedAdmins sit in ExampleUsers but should not + # receive desktop restrictions (they need regedit, cmd, etc. for sysadmin work). + # MasterAdmins are in ExampleAdmins OU so they never receive this GPO anyway. + SecurityFiltering = @{ + DenyApply = @('DelegatedAdmins') + } + + # No security policy settings -- user rights, account policies, etc. are Computer Configuration only + SecurityPolicy = @{} + + RegistrySettings = @( + + # ============================================================= + # Desktop Hardening + # ============================================================= + + # Prevent access to registry editing tools + @{ + Key = 'HKCU\Software\Microsoft\Windows\CurrentVersion\Policies\System' + ValueName = 'DisableRegistryTools' + Type = 'DWord' + Value = 1 + } + + # Prevent access to command prompt (2 = disable cmd.exe but allow batch files) + @{ + Key = 'HKCU\Software\Policies\Microsoft\Windows\System' + ValueName = 'DisableCMD' + Type = 'DWord' + Value = 2 + } + + # Remove Run from Start Menu + @{ + Key = 'HKCU\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer' + ValueName = 'NoRun' + Type = 'DWord' + Value = 1 + } + + # Prevent changing desktop wallpaper + @{ + Key = 'HKCU\Software\Microsoft\Windows\CurrentVersion\Policies\ActiveDesktop' + ValueName = 'NoChangingWallPaper' + Type = 'DWord' + Value = 1 + } + + # Remove Add/Remove Programs from Control Panel + @{ + Key = 'HKCU\Software\Microsoft\Windows\CurrentVersion\Policies\Uninstall' + ValueName = 'NoAddRemovePrograms' + Type = 'DWord' + Value = 1 + } + + # Prevent adding printers + @{ + Key = 'HKCU\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer' + ValueName = 'NoAddPrinter' + Type = 'DWord' + Value = 1 + } + + # ============================================================= + # UX Standardization + # ============================================================= + + # Set default desktop wallpaper (built-in Windows wallpaper, exists on all machines) + # Replace with a corporate wallpaper on a UNC share when ready + @{ + Key = 'HKCU\Software\Microsoft\Windows\CurrentVersion\Policies\System' + ValueName = 'Wallpaper' + Type = 'String' + Value = 'C:\Windows\Web\Wallpaper\Windows\img0.jpg' + } + + # Wallpaper style: Fill + # 0=Center, 2=Stretch, 6=Fit, 10=Fill, 22=Span + @{ + Key = 'HKCU\Software\Microsoft\Windows\CurrentVersion\Policies\System' + ValueName = 'WallpaperStyle' + Type = 'String' + Value = '10' + } + + # Hide Search box on taskbar (0=Hidden, 1=Icon, 2=Full box) + @{ + Key = 'HKCU\Software\Microsoft\Windows\CurrentVersion\Search' + ValueName = 'SearchboxTaskbarMode' + Type = 'DWord' + Value = 0 + } + + # Hide Task View button on taskbar + @{ + Key = 'HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' + ValueName = 'ShowTaskViewButton' + Type = 'DWord' + Value = 0 + } + + # Disable Windows Copilot + @{ + Key = 'HKCU\Software\Policies\Microsoft\Windows\WindowsCopilot' + ValueName = 'TurnOffWindowsCopilot' + Type = 'DWord' + Value = 1 + } + + # Hide Widgets on taskbar + @{ + Key = 'HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' + ValueName = 'TaskbarDa' + Type = 'DWord' + Value = 0 + } + ) +} diff --git a/gpo/workstations-01/README.md b/gpo/workstations-01/README.md new file mode 100644 index 0000000..ff610de --- /dev/null +++ b/gpo/workstations-01/README.md @@ -0,0 +1,96 @@ +# Workstations-01 GPO + +**GUID:** Auto-created on first `Apply-GPOBaseline.ps1` run +**Linked to:** `OU=ExampleWorkstations,DC=example,DC=internal` +**Scope:** Computer Configuration (HKLM) -- Security Policy + Administrative Templates + +This GPO applies to all computer objects in the ExampleWorkstations OU. It uses both SecurityPolicy (GptTmpl.inf) and RegistrySettings (Set-GPRegistryValue). + +## Settings + +### Security Policy (GptTmpl.inf) + +#### System Access + +| Setting | Value | Effect | +|---|---|---| +| EnableGuestAccount | 0 | Disables the local guest account | + +#### Event Audit + +| Setting | Value | Effect | +|---|---|---| +| AuditSystemEvents | 1 | Success | +| AuditLogonEvents | 3 | Success + Failure | +| AuditObjectAccess | 2 | Failure | +| AuditPrivilegeUse | 2 | Failure | +| AuditPolicyChange | 1 | Success | +| AuditAccountManage | 3 | Success + Failure | +| AuditProcessTracking | 0 | No auditing | +| AuditDSAccess | 0 | No auditing (irrelevant for workstations) | +| AuditAccountLogon | 3 | Success + Failure | + +#### Security Options (Registry Values in GptTmpl.inf) + +| Setting | Value | Effect | +|---|---|---| +| InactivityTimeoutSecs | 900 | Lock screen after 15 minutes idle | +| DontDisplayLastUserName | 1 | Login screen does not reveal usernames | +| DisableCAD | 0 | Ctrl+Alt+Del required at login | + +### Administrative Templates (Registry-based) + +#### Autorun / Autoplay + +| Setting | Value | Effect | +|---|---|---| +| NoDriveTypeAutoRun | 255 | Disable autorun on all drive types | +| NoAutorun | 1 | Disable autoplay entirely | + +#### Windows Update + +| Setting | Value | Effect | +|---|---|---| +| NoAutoUpdate | 0 | Automatic updates enabled | +| AUOptions | 4 | Auto download + scheduled install | +| ScheduledInstallDay | 0 | Every day | +| ScheduledInstallTime | 3 | 3:00 AM | + +#### Logging & Auditing + +| Setting | Value | Effect | +|---|---|---| +| EnableScriptBlockLogging | 1 | PowerShell script block logging enabled | +| Application MaxSize | 32768 KB | 32 MB application event log | +| Security MaxSize | 196608 KB | 192 MB security event log | +| System MaxSize | 32768 KB | 32 MB system event log | + +#### Remote Desktop + +| Setting | Value | Effect | +|---|---|---| +| UserAuthentication | 1 | Network Level Authentication required for RDP | + +## WMI Filter + +| Property | Value | +|---|---| +| Name | Workstations Only | +| Query | `SELECT * FROM Win32_OperatingSystem WHERE ProductType = 1` | + +Defense-in-depth: ensures this GPO only applies to workstation operating systems, even if a server object lands in the ExampleWorkstations OU by mistake. + +## Restricted Groups + +| Local Group | Enforced Members | +|---|---| +| BUILTIN\Administrators | Domain Admins, MasterAdmins | + +Any locally-added administrator accounts are removed on next GPO refresh. This prevents local admin creep on workstations. + +## Notes + +- First GPO in this repo to use both SecurityPolicy and RegistrySettings together +- Audit policy uses legacy categories (not Advanced Audit Policy Configuration subcategories) +- Event log sizes are generous -- 192 MB security log supports forensic investigation +- Windows Update schedule assumes workstations are powered on overnight or use wake timers diff --git a/gpo/workstations-01/settings.ps1 b/gpo/workstations-01/settings.ps1 new file mode 100644 index 0000000..03919b6 --- /dev/null +++ b/gpo/workstations-01/settings.ps1 @@ -0,0 +1,288 @@ +# Workstations-01 -- Settings Declaration +# Linked to: OU=ExampleWorkstations,DC=example,DC=internal +# +# This GPO targets computer configuration for the ExampleWorkstations OU. +# All settings are Computer Configuration (HKLM) -- both Security Policy and Administrative Templates. + +@{ + GPOName = 'Workstations-01' + Description = 'Workstation hardening -- security, updates, logging, and remote desktop' + + DisableUserConfiguration = $true + + LinkTo = 'OU=ExampleWorkstations,DC=example,DC=internal' + + # Defense-in-depth: only apply to workstation OS (ProductType 1). + # Prevents misapplication if a server object lands in the wrong OU. + WMIFilter = @{ + Name = 'Workstations Only' + Description = 'Targets workstation operating systems (ProductType = 1)' + Query = "SELECT * FROM Win32_OperatingSystem WHERE ProductType = 1" + } + + # Lock down local Administrators to Domain Admins + MasterAdmins only. + # Any unauthorized additions are removed on next GPO refresh. + RestrictedGroups = @{ + 'BUILTIN\Administrators' = @{ + Members = @('EXAMPLE\Domain Admins', 'EXAMPLE\MasterAdmins') + } + } + + SecurityPolicy = @{ + + 'System Access' = [ordered]@{ + # Disable local guest account + EnableGuestAccount = 0 + } + + 'Event Audit' = [ordered]@{ + # Audit values: 0=None, 1=Success, 2=Failure, 3=Success+Failure + AuditSystemEvents = 1 # Success + AuditLogonEvents = 3 # Success + Failure + AuditObjectAccess = 2 # Failure + AuditPrivilegeUse = 2 # Failure + AuditPolicyChange = 1 # Success + AuditAccountManage = 3 # Success + Failure + AuditProcessTracking = 0 # No auditing + AuditDSAccess = 0 # No auditing (irrelevant for workstations) + AuditAccountLogon = 3 # Success + Failure + } + + 'Registry Values' = [ordered]@{ + # Interactive logon: Machine inactivity limit (seconds) - lock after 15 min + 'MACHINE\Software\Microsoft\Windows\CurrentVersion\Policies\System\InactivityTimeoutSecs' = '4,900' + + # Interactive logon: Don't display last signed-in user + 'MACHINE\Software\Microsoft\Windows\CurrentVersion\Policies\System\DontDisplayLastUserName' = '4,1' + + # Interactive logon: Do not require CTRL+ALT+DEL = Disabled (i.e. require it) + 'MACHINE\Software\Microsoft\Windows NT\CurrentVersion\Winlogon\DisableCAD' = '4,0' + } + } + + # ================================================================= + # Windows Firewall -- default-deny inbound, minimal exceptions + # Standard workstations should not host inbound services. + # Only ICMP for troubleshooting. + # ================================================================= + + FirewallProfiles = @{ + Domain = @{ Enabled = $true; DefaultInboundAction = 'Block'; DefaultOutboundAction = 'Allow' } + Private = @{ Enabled = $true; DefaultInboundAction = 'Block'; DefaultOutboundAction = 'Allow' } + Public = @{ Enabled = $true; DefaultInboundAction = 'Block'; DefaultOutboundAction = 'Allow' } + } + + FirewallRules = @( + # ICMP Echo -- network troubleshooting + @{ + DisplayName = 'Allow ICMP Echo Request (Inbound)' + Direction = 'Inbound' + Action = 'Allow' + Protocol = 'ICMPv4' + Profile = 'Domain' + Description = 'ICMP echo for ping-based connectivity testing' + } + ) + + # ================================================================= + # AppLocker -- audit mode (logs violations, does not block) + # Standard baseline: allow Microsoft-signed, allow from Windows + # and Program Files, allow admins unrestricted. Deploy in AuditOnly + # first to identify any legitimate apps that would be blocked. + # ================================================================= + + AppLockerPolicy = @{ + Exe = @{ + EnforcementMode = 'AuditOnly' + Rules = @( + @{ + Type = 'Publisher' + Name = 'Allow Microsoft-signed executables' + Action = 'Allow' + User = 'Everyone' + Publisher = 'O=MICROSOFT CORPORATION, L=REDMOND, S=WASHINGTON, C=US' + Product = '*' + Binary = '*' + } + @{ + Type = 'Path' + Name = 'Allow executables in Program Files' + Action = 'Allow' + User = 'Everyone' + Path = '%PROGRAMFILES%\*' + } + @{ + Type = 'Path' + Name = 'Allow executables in Windows directory' + Action = 'Allow' + User = 'Everyone' + Path = '%WINDIR%\*' + } + @{ + Type = 'Path' + Name = 'Allow administrators unrestricted' + Action = 'Allow' + User = 'BUILTIN\Administrators' + Path = '*' + } + ) + } + Msi = @{ + EnforcementMode = 'AuditOnly' + Rules = @( + @{ + Type = 'Publisher' + Name = 'Allow Microsoft-signed installers' + Action = 'Allow' + User = 'Everyone' + Publisher = 'O=MICROSOFT CORPORATION, L=REDMOND, S=WASHINGTON, C=US' + Product = '*' + Binary = '*' + } + @{ + Type = 'Path' + Name = 'Allow administrators unrestricted' + Action = 'Allow' + User = 'BUILTIN\Administrators' + Path = '*' + } + ) + } + Script = @{ + EnforcementMode = 'AuditOnly' + Rules = @( + @{ + Type = 'Path' + Name = 'Allow scripts in Windows directory' + Action = 'Allow' + User = 'Everyone' + Path = '%WINDIR%\*' + } + @{ + Type = 'Path' + Name = 'Allow scripts in Program Files' + Action = 'Allow' + User = 'Everyone' + Path = '%PROGRAMFILES%\*' + } + @{ + Type = 'Path' + Name = 'Allow administrators unrestricted' + Action = 'Allow' + User = 'BUILTIN\Administrators' + Path = '*' + } + ) + } + } + + RegistrySettings = @( + + # ============================================================= + # Autorun / Autoplay + # ============================================================= + + # Disable autorun on all drive types (bitmask 0xFF = all) + @{ + Key = 'HKLM\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer' + ValueName = 'NoDriveTypeAutoRun' + Type = 'DWord' + Value = 255 + } + + # Disable autoplay entirely + @{ + Key = 'HKLM\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer' + ValueName = 'NoAutorun' + Type = 'DWord' + Value = 1 + } + + # ============================================================= + # Windows Update + # ============================================================= + + # Enable automatic updates + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\WindowsUpdate\AU' + ValueName = 'NoAutoUpdate' + Type = 'DWord' + Value = 0 + } + + # Auto download and schedule install + # 2=Notify, 3=Auto download + notify, 4=Auto download + schedule, 5=Local admin chooses + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\WindowsUpdate\AU' + ValueName = 'AUOptions' + Type = 'DWord' + Value = 4 + } + + # Schedule install day (0=Every day, 1=Sunday ... 7=Saturday) + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\WindowsUpdate\AU' + ValueName = 'ScheduledInstallDay' + Type = 'DWord' + Value = 0 + } + + # Schedule install time (0-23, 3 = 3:00 AM) + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\WindowsUpdate\AU' + ValueName = 'ScheduledInstallTime' + Type = 'DWord' + Value = 3 + } + + # ============================================================= + # Logging & Auditing + # ============================================================= + + # Enable PowerShell script block logging + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging' + ValueName = 'EnableScriptBlockLogging' + Type = 'DWord' + Value = 1 + } + + # Event log max sizes (in KB) + + # Application log: 32 MB + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\EventLog\Application' + ValueName = 'MaxSize' + Type = 'DWord' + Value = 32768 + } + + # Security log: 192 MB + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\EventLog\Security' + ValueName = 'MaxSize' + Type = 'DWord' + Value = 196608 + } + + # System log: 32 MB + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows\EventLog\System' + ValueName = 'MaxSize' + Type = 'DWord' + Value = 32768 + } + + # ============================================================= + # Remote Desktop + # ============================================================= + + # Require Network Level Authentication for RDP connections + @{ + Key = 'HKLM\Software\Policies\Microsoft\Windows NT\Terminal Services' + ValueName = 'UserAuthentication' + Type = 'DWord' + Value = 1 + } + ) +}