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.
This commit is contained in:
Damien Coles 2026-02-19 17:02:42 +00:00
commit f172d00514
51 changed files with 10180 additions and 0 deletions

14
.gitignore vendored Normal file
View File

@ -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/

173
CHANGELOG.md Normal file
View File

@ -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

1398
FRAMEWORK.md Normal file

File diff suppressed because it is too large Load Diff

254
README.md Normal file
View File

@ -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/<gpo-name>/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/<username>.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/<gpo-name>/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/<new-name>/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.

View File

@ -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
}
}

View File

@ -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

View File

@ -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:<attributeName>' - 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'
)
}
)

20
ad-objects/groups.ps1 Normal file
View File

@ -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')
}
)

55
ad-objects/lib/ADCore.ps1 Normal file
View File

@ -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
}

View File

@ -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:<attribute>"
}
}
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
}

113
ad-objects/lib/ADGroup.ps1 Normal file
View File

@ -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
}

View File

@ -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')

View File

@ -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' }
}
}

View File

@ -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
}

200
ad-objects/lib/ADUser.ps1 Normal file
View File

@ -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
}

32
ad-objects/ous.ps1 Normal file
View File

@ -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'
}
)

View File

@ -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')
}
)

47
ad-objects/users.ps1 Normal file
View File

@ -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 = @()
}
)

92
gpo/Apply-DSCBaseline.ps1 Normal file
View File

@ -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

376
gpo/Apply-GPOBaseline.ps1 Normal file
View File

@ -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
}

135
gpo/Get-UnmanagedGPOs.ps1 Normal file
View File

@ -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

View File

@ -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 '<name>' -Timestamp '<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

41
gpo/admins-01/README.md Normal file
View File

@ -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.

View File

@ -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
}
)
}

View File

@ -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 ==='

View File

@ -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 |

View File

@ -0,0 +1,169 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
WDAC Baseline: AllowMicrosoft (Audit Mode)
Trusts all binaries signed by Microsoft root certificates and WHQL-certified
drivers. Audit mode logs CodeIntegrity event 3076 for anything that would be
blocked, without actually blocking execution.
Review audit logs before switching to enforce mode:
Get-WinEvent -LogName 'Microsoft-Windows-CodeIntegrity/Operational' |
Where-Object Id -eq 3076
Based on Microsoft's AllowMicrosoft template.
-->
<SiPolicy xmlns="urn:schemas-microsoft-com:sipolicy" PolicyType="Base Policy">
<VersionEx>10.0.1.0</VersionEx>
<PolicyID>{7BE95702-9AD4-402C-BCCE-87D8587E0F7D}</PolicyID>
<BasePolicyID>{7BE95702-9AD4-402C-BCCE-87D8587E0F7D}</BasePolicyID>
<PlatformID>{2E07F7E4-194C-4D20-B7C9-6F44A6C5A234}</PlatformID>
<Rules>
<!-- User-mode code integrity: enforce policy on applications, not just drivers -->
<Rule><Option>Enabled:UMCI</Option></Rule>
<!-- AUDIT MODE: log violations (event 3076) without blocking -->
<Rule><Option>Enabled:Audit Mode</Option></Rule>
<!-- Do not trust Windows Insider / flighting-signed binaries -->
<Rule><Option>Disabled:Flight Signing</Option></Rule>
<!-- Policy is unsigned (no secure boot signing required) -->
<Rule><Option>Enabled:Unsigned System Integrity Policy</Option></Rule>
<!-- Allow F8 boot menu for physically present users -->
<Rule><Option>Enabled:Advanced Boot Options Menu</Option></Rule>
<!-- Policy updates apply without reboot -->
<Rule><Option>Enabled:Update Policy No Reboot</Option></Rule>
<!-- Allow supplemental policies to extend this base -->
<Rule><Option>Enabled:Allow Supplemental Policies</Option></Rule>
</Rules>
<EKUs>
<EKU ID="ID_EKU_WINDOWS" FriendlyName="Windows System Component Verification - 1.3.6.1.4.1.311.10.3.6" Value="010A2B0601040182370A0306" />
<EKU ID="ID_EKU_WHQL" FriendlyName="WHQL Crypto - 1.3.6.1.4.1.311.10.3.5" Value="010A2B0601040182370A0305" />
<EKU ID="ID_EKU_ELAM" FriendlyName="Early Launch AntiMalware - 1.3.6.1.4.1.311.61.4.1" Value="010A2B0601040182373D0401" />
<EKU ID="ID_EKU_HAL_EXT" FriendlyName="HAL Extension - 1.3.6.1.4.1.311.61.5.1" Value="010A2B0601040182373D0501" />
<EKU ID="ID_EKU_STORE" FriendlyName="Windows Store - 1.3.6.1.4.1.311.76.3.1" Value="010A2B0601040182374C0301" />
</EKUs>
<FileRules>
<!-- RefreshPolicy.exe: allows policy refresh without reboot -->
<FileAttrib ID="ID_FILEATTRIB_REFRESH_POLICY" FriendlyName="RefreshPolicy.exe FileAttribute" FileName="RefreshPolicy.exe" MinimumFileVersion="10.0.19042.0" />
</FileRules>
<Signers>
<!-- ============================================================= -->
<!-- Kernel-mode signers -->
<!-- ============================================================= -->
<!-- Microsoft Product Root 2010: all Microsoft-signed binaries -->
<Signer ID="ID_SIGNER_MICROSOFT_PRODUCTION" Name="Microsoft Product Root 2010">
<CertRoot Type="Wellknown" Value="06" />
</Signer>
<!-- Microsoft Product Root 2001: legacy Microsoft-signed binaries -->
<Signer ID="ID_SIGNER_MICROSOFT_2001" Name="Microsoft Product Root 2001">
<CertRoot Type="Wellknown" Value="05" />
</Signer>
<!-- Microsoft Product Root 1997: oldest legacy binaries -->
<Signer ID="ID_SIGNER_MICROSOFT_1997" Name="Microsoft Product Root 1997">
<CertRoot Type="Wellknown" Value="04" />
</Signer>
<!-- Microsoft Standard Root 2011: standard-signed applications -->
<Signer ID="ID_SIGNER_MICROSOFT_STANDARD" Name="Microsoft Standard Root 2011">
<CertRoot Type="Wellknown" Value="07" />
</Signer>
<!-- Microsoft Code Verification Root 2006 -->
<Signer ID="ID_SIGNER_MICROSOFT_CODEVERIF" Name="Microsoft Code Verification Root 2006">
<CertRoot Type="Wellknown" Value="08" />
</Signer>
<!-- ============================================================= -->
<!-- User-mode signer duplicates (same roots, separate IDs) -->
<!-- ============================================================= -->
<Signer ID="ID_SIGNER_MICROSOFT_PRODUCTION_USER" Name="Microsoft Product Root 2010">
<CertRoot Type="Wellknown" Value="06" />
</Signer>
<Signer ID="ID_SIGNER_MICROSOFT_2001_USER" Name="Microsoft Product Root 2001">
<CertRoot Type="Wellknown" Value="05" />
</Signer>
<Signer ID="ID_SIGNER_MICROSOFT_1997_USER" Name="Microsoft Product Root 1997">
<CertRoot Type="Wellknown" Value="04" />
</Signer>
<Signer ID="ID_SIGNER_MICROSOFT_STANDARD_USER" Name="Microsoft Standard Root 2011">
<CertRoot Type="Wellknown" Value="07" />
</Signer>
<Signer ID="ID_SIGNER_MICROSOFT_CODEVERIF_USER" Name="Microsoft Code Verification Root 2006">
<CertRoot Type="Wellknown" Value="08" />
</Signer>
<!-- ============================================================= -->
<!-- User-mode-only signers -->
<!-- ============================================================= -->
<!-- Windows Store (MarketPlace PCA 2011) -->
<Signer ID="ID_SIGNER_STORE" Name="Microsoft MarketPlace PCA 2011">
<CertRoot Type="TBS" Value="FC9EDE3DCCA09186B2D3BF9B738A2050CB1A554DA2DCADB55F3F72EE17721378" />
<CertEKU ID="ID_EKU_STORE" />
</Signer>
<!-- RefreshPolicy.exe signer (Code Signing PCA 2011) -->
<Signer ID="ID_SIGNER_MICROSOFT_REFRESH_POLICY" Name="Microsoft Code Signing PCA 2011">
<CertRoot Type="TBS" Value="F6F717A43AD9ABDDC8CEFDDE1C505462535E7D1307E630F9544A2D14FE8BF26E" />
<CertPublisher Value="Microsoft Corporation" />
<FileAttribRef RuleID="ID_FILEATTRIB_REFRESH_POLICY" />
</Signer>
</Signers>
<SigningScenarios>
<!-- Kernel Mode (131): drivers and kernel components -->
<SigningScenario Value="131" ID="ID_SIGNINGSCENARIO_KMCI" FriendlyName="Kernel Mode Signing Scenario">
<ProductSigners>
<AllowedSigners>
<AllowedSigner SignerId="ID_SIGNER_MICROSOFT_PRODUCTION" />
<AllowedSigner SignerId="ID_SIGNER_MICROSOFT_2001" />
<AllowedSigner SignerId="ID_SIGNER_MICROSOFT_1997" />
<AllowedSigner SignerId="ID_SIGNER_MICROSOFT_STANDARD" />
<AllowedSigner SignerId="ID_SIGNER_MICROSOFT_CODEVERIF" />
</AllowedSigners>
</ProductSigners>
</SigningScenario>
<!-- User Mode (12): applications, DLLs, scripts -->
<SigningScenario Value="12" ID="ID_SIGNINGSCENARIO_UMCI" FriendlyName="User Mode Signing Scenario">
<ProductSigners>
<AllowedSigners>
<AllowedSigner SignerId="ID_SIGNER_MICROSOFT_PRODUCTION_USER" />
<AllowedSigner SignerId="ID_SIGNER_MICROSOFT_2001_USER" />
<AllowedSigner SignerId="ID_SIGNER_MICROSOFT_1997_USER" />
<AllowedSigner SignerId="ID_SIGNER_MICROSOFT_STANDARD_USER" />
<AllowedSigner SignerId="ID_SIGNER_MICROSOFT_CODEVERIF_USER" />
<AllowedSigner SignerId="ID_SIGNER_STORE" />
<AllowedSigner SignerId="ID_SIGNER_MICROSOFT_REFRESH_POLICY" />
</AllowedSigners>
</ProductSigners>
</SigningScenario>
</SigningScenarios>
<UpdatePolicySigners />
<CiSigners>
<CiSigner SignerId="ID_SIGNER_STORE" />
</CiSigners>
<HvciOptions>0</HvciOptions>
<Settings>
<Setting Provider="PolicyInfo" Key="Information" ValueName="Name">
<Value><String>Example-WDAC-AllowMicrosoft-Audit</String></Value>
</Setting>
<Setting Provider="PolicyInfo" Key="Information" ValueName="Id">
<Value><String>7BE95702</String></Value>
</Setting>
</Settings>
</SiPolicy>

View File

@ -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'
}
)
}

View File

@ -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']
}
}
}

View File

@ -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 = @()
}

View File

@ -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
}
}
}
}
}

View File

@ -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 = @()
}

272
gpo/lib/GPOAppLocker.ps1 Normal file
View File

@ -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 { '*' }
@"
<Conditions>
<FilePublisherCondition PublisherName="$pub" ProductName="$prod" BinaryName="$bin">
<BinaryVersionRange LowSection="$lowVer" HighSection="$highVer"/>
</FilePublisherCondition>
</Conditions>
"@
}
'Path' {
$path = $esc::Escape($rule.Path)
@"
<Conditions>
<FilePathCondition Path="$path"/>
</Conditions>
"@
}
'Hash' {
$hash = $rule.Hash
$fileName = if ($rule.FileName) { $esc::Escape($rule.FileName) } else { '' }
$fileLength = if ($rule.SourceFileLength) { $rule.SourceFileLength } else { '0' }
@"
<Conditions>
<FileHashCondition>
<FileHash Type="SHA256" Data="$hash" SourceFileName="$fileName" SourceFileLength="$fileLength"/>
</FileHashCondition>
</Conditions>
"@
}
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
</$elementName>
"@
}
@"
<RuleCollection Type="$xmlType" EnforcementMode="$enforcement">
$($ruleXml -join "`n")
</RuleCollection>
"@
}
return @"
<AppLockerPolicy Version="1">
$($collections -join "`n")
</AppLockerPolicy>
"@
}
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
}

217
gpo/lib/GPOAudit.ps1 Normal file
View File

@ -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
}

214
gpo/lib/GPOBackup.ps1 Normal file
View File

@ -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
}

273
gpo/lib/GPOCore.ps1 Normal file
View File

@ -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]
}

303
gpo/lib/GPOFirewall.ps1 Normal file
View File

@ -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
}

View File

@ -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
}

22
gpo/lib/GPOHelper.ps1 Normal file
View File

@ -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')

351
gpo/lib/GPOPermissions.ps1 Normal file
View File

@ -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'
}
}
}

377
gpo/lib/GPOPolicy.ps1 Normal file
View File

@ -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
}
}
}
}
}

837
gpo/lib/GPOPreferences.ps1 Normal file
View File

@ -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 <Filters> 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' }
" <FilterGroup bool=`"$bool`" not=`"$not`" name=`"$name`" sid=`"$sid`" userContext=`"$userContext`" primaryGroup=`"$primaryGroup`" localGroup=`"$localGroup`"/>"
}
'OrgUnit' {
$type = if ($f.ContainsKey('OUType')) { $f.OUType } else { '' }
" <FilterOrgUnit bool=`"$bool`" not=`"$not`" name=`"$name`" type=`"$type`"/>"
}
'Computer' {
$type = if ($f.ContainsKey('NameType')) { $f.NameType } else { 'NETBIOS' }
" <FilterComputer bool=`"$bool`" not=`"$not`" type=`"$type`" name=`"$name`"/>"
}
'User' {
$type = if ($f.ContainsKey('NameType')) { $f.NameType } else { 'NETBIOS' }
" <FilterUser bool=`"$bool`" not=`"$not`" type=`"$type`" name=`"$name`"/>"
}
'OperatingSystem' {
$edition = if ($f.Edition) { $esc::Escape($f.Edition) } else { '' }
$version = if ($f.Version) { $esc::Escape($f.Version) } else { '' }
" <FilterOperatingSystem bool=`"$bool`" not=`"$not`" type=`"$name`" edition=`"$edition`" version=`"$version`"/>"
}
'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)
" <FilterWmi bool=`"$bool`" not=`"$not`" name=`"$name`" property=`"$property`" query=`"$query`" namespace=`"$namespace`"/>"
}
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 <Filters>`n$($validFilters -join "`n")`n </Filters>"
}
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' { '<BootTrigger><Enabled>true</Enabled></BootTrigger>' }
'AtLogon' { '<LogonTrigger><Enabled>true</Enabled></LogonTrigger>' }
default { '<BootTrigger><Enabled>true</Enabled></BootTrigger>' }
}
$filterBlock = ConvertTo-ILTFilterXml -Filters $task.Filters
@"
<TaskV2 clsid="{D8896631-B747-47a7-84A6-C155337F3BC8}" name="$name" image="$image" changed="$timestamp" uid="$uid" userContext="$userContext" removePolicy="0">
<Properties action="$action" name="$name" runAs="$runAs" logonType="S4U">
<Task version="1.2">
<RegistrationInfo><Description></Description></RegistrationInfo>
<Principals>
<Principal id="Author">
<UserId>$runAs</UserId>
<LogonType>S4U</LogonType>
<RunLevel>HighestAvailable</RunLevel>
</Principal>
</Principals>
<Settings>
<IdleSettings>
<Duration>PT10M</Duration>
<WaitTimeout>PT1H</WaitTimeout>
<StopOnIdleEnd>true</StopOnIdleEnd>
<RestartOnIdle>false</RestartOnIdle>
</IdleSettings>
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<AllowHardTerminate>true</AllowHardTerminate>
<StartWhenAvailable>true</StartWhenAvailable>
<AllowStartOnDemand>true</AllowStartOnDemand>
<Enabled>true</Enabled>
<Hidden>false</Hidden>
<ExecutionTimeLimit>PT72H</ExecutionTimeLimit>
<Priority>7</Priority>
</Settings>
<Triggers>$trigger</Triggers>
<Actions>
<Exec>
<Command>$command</Command>
<Arguments>$arguments</Arguments>
</Exec>
</Actions>
</Task>
</Properties>$filterBlock
</TaskV2>
"@
}
return @"
<?xml version="1.0" encoding="utf-8"?>
<ScheduledTasks clsid="{CC63F200-7309-4ba0-B154-A71CD118DBCC}">
$($itemsXml -join "`n")
</ScheduledTasks>
"@
}
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
@"
<Drive clsid="{935D1B74-9CB8-4e3c-9914-7DD559B7A417}" name="${letter}:" image="$image" changed="$timestamp" uid="$uid" userContext="1" removePolicy="0">
<Properties action="$action" thisDrive="NOCHANGE" allDrives="NOCHANGE" userName="" path="$path" label="$label" persistent="$persistent" useLetter="1" letter="$letter"/>$filterBlock
</Drive>
"@
}
return @"
<?xml version="1.0" encoding="utf-8"?>
<Drives clsid="{8FDDCC1A-0C3C-43cd-A6B4-71A6DF20DA8C}">
$($itemsXml -join "`n")
</Drives>
"@
}
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
@"
<EnvironmentVariable clsid="{B1A3FA3F-E5D1-41ea-88D1-55CE96B6C78B}" name="$name" image="$image" changed="$timestamp" uid="$uid" userContext="$userContext">
<Properties action="$action" name="$name" value="$value" user="$user"/>$filterBlock
</EnvironmentVariable>
"@
}
return @"
<?xml version="1.0" encoding="utf-8"?>
<EnvironmentVariables clsid="{D76B9641-3288-4f75-942D-087DE603E3EA}">
$($itemsXml -join "`n")
</EnvironmentVariables>
"@
}
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
@"
<NTService clsid="{AB6F0B67-D4E1-4f59-A9E5-F6BD3A72F4FF}" name="$serviceName" image="$image" changed="$timestamp" uid="$uid" userContext="0" removePolicy="0">
<Properties startupType="$startupType" serviceName="$serviceName" serviceAction="NONE" timeout="30"/>$filterBlock
</NTService>
"@
}
return @"
<?xml version="1.0" encoding="utf-8"?>
<NTServices clsid="{2CFB484A-4E86-4eb1-8B6A-E1535488BDBF}">
$($itemsXml -join "`n")
</NTServices>
"@
}
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
@"
<SharedPrinter clsid="{9A5E9697-9095-436d-A0EE-4D128FDFBCE5}" name="$name" image="$image" changed="$timestamp" uid="$uid" userContext="1" removePolicy="0">
<Properties action="$action" path="$path" comment="$comment" location="$location" default="$default" skipLocal="$skipLocal" deleteAll="0" persistent="0" deleteMaps="0" port=""/>$filterBlock
</SharedPrinter>
"@
}
return @"
<?xml version="1.0" encoding="utf-8"?>
<Printers clsid="{1F577D12-3D1B-471e-A1B7-060317597B9C}">
$($itemsXml -join "`n")
</Printers>
"@
}
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
@"
<Shortcut clsid="{4F2F7C55-2790-433e-8127-0739D1CFA327}" name="$name" image="$image" changed="$timestamp" uid="$uid" userContext="$userContext" removePolicy="0">
<Properties action="$action" targetType="$targetType" targetPath="$targetPath" shortcutPath="$shortcutPath" arguments="$arguments" startIn="$startIn" comment="$comment" shortcutKey="0" iconPath="$iconPath" iconIndex="$iconIndex" window="" pidl=""/>$filterBlock
</Shortcut>
"@
}
return @"
<?xml version="1.0" encoding="utf-8"?>
<Shortcuts clsid="{872ECB34-B2EC-401b-A585-D32574AA90EE}">
$($itemsXml -join "`n")
</Shortcuts>
"@
}
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
@"
<File clsid="{50BE44C8-567A-4ed1-B1D0-9234FE1F38AF}" name="$targetPath" image="$image" changed="$timestamp" uid="$uid" userContext="$userContext" removePolicy="0">
<Properties action="$action" fromPath="$fromPath" targetPath="$targetPath" readOnly="$readOnly" archive="1" hidden="$hidden" suppress="1"/>$filterBlock
</File>
"@
}
return @"
<?xml version="1.0" encoding="utf-8"?>
<Files clsid="{215B2E53-57CE-475c-80FE-9EEC14635851}">
$($itemsXml -join "`n")
</Files>
"@
}
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
@"
<NetShare clsid="{2888C5E7-94FC-4739-90AA-2C1536D68BC0}" name="$name" image="$image" changed="$timestamp" uid="$uid" userContext="0" removePolicy="0">
<Properties action="$action" name="$name" path="$path" comment="$comment" allRegular="$allRegular" allHidden="$allHidden" allAdminDrive="$allAdminDrive" limitUsers="$limitUsers"/>$filterBlock
</NetShare>
"@
}
return @"
<?xml version="1.0" encoding="utf-8"?>
<NetworkShareSettings clsid="{520870D8-A6E7-47e8-A8D8-E6A4E76EAEC2}">
$($itemsXml -join "`n")
</NetworkShareSettings>
"@
}
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
@"
<Registry clsid="{9CD4B2F4-923D-47f5-A062-E897DD1DAD50}" name="$displayName" image="$image" changed="$timestamp" uid="$uid" userContext="$userContext" removePolicy="0">
<Properties action="$action" hive="$hive" key="$key" name="$name" type="$type" value="$value" displayDecimal="0" default="0"/>$filterBlock
</Registry>
"@
}
return @"
<?xml version="1.0" encoding="utf-8"?>
<RegistrySettings clsid="{A3CCFC41-DFDB-43a5-8D26-0FE8B954DA51}">
$($itemsXml -join "`n")
</RegistrySettings>
"@
}
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 <Members> 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
}
" <Member name=`"$memberName`" action=`"$memberAction`" sid=`"$sid`"/>"
}
$membersXml = "`n <Members>`n$($memberLines -join "`n")`n </Members>"
}
$filterBlock = ConvertTo-ILTFilterXml -Filters $group.Filters
@"
<Group clsid="{6D4A79E4-529C-4481-ABD0-F5BD7EA93BA7}" name="$groupName" image="$image" changed="$timestamp" uid="$uid" userContext="0" removePolicy="0">
<Properties action="$action" groupName="$groupName" newName="$newName" description="$description" deleteAllUsers="$deleteAllUsers" deleteAllGroups="$deleteAllGroups" removeAccounts="$removeAccounts">$membersXml
</Properties>$filterBlock
</Group>
"@
}
return @"
<?xml version="1.0" encoding="utf-8"?>
<Groups clsid="{3125E937-EB16-4b4c-9934-544FC6D24D26}">
$($itemsXml -join "`n")
</Groups>
"@
}
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
}

271
gpo/lib/GPOScripts.ps1 Normal file
View File

@ -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
}

181
gpo/lib/GPOWdac.ps1 Normal file
View File

@ -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
}

190
gpo/lib/GPOWmiFilter.ps1 Normal file
View File

@ -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;<queryLen>;WQL;root\CIMv2;<query>;
# 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
}

115
gpo/servers-01/README.md Normal file
View File

@ -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 |

357
gpo/servers-01/settings.ps1 Normal file
View File

@ -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
}
)
}

38
gpo/users-01/README.md Normal file
View File

@ -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

133
gpo/users-01/settings.ps1 Normal file
View File

@ -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
}
)
}

View File

@ -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

View File

@ -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
}
)
}