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:
commit
f172d00514
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal 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
173
CHANGELOG.md
Normal 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
1398
FRAMEWORK.md
Normal file
File diff suppressed because it is too large
Load Diff
254
README.md
Normal file
254
README.md
Normal 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.
|
||||
231
ad-objects/Apply-ADBaseline.ps1
Normal file
231
ad-objects/Apply-ADBaseline.ps1
Normal 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
|
||||
}
|
||||
}
|
||||
202
ad-objects/Get-StaleADObjects.ps1
Normal file
202
ad-objects/Get-StaleADObjects.ps1
Normal 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
|
||||
46
ad-objects/delegations.ps1
Normal file
46
ad-objects/delegations.ps1
Normal 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
20
ad-objects/groups.ps1
Normal 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
55
ad-objects/lib/ADCore.ps1
Normal 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
|
||||
}
|
||||
232
ad-objects/lib/ADDelegation.ps1
Normal file
232
ad-objects/lib/ADDelegation.ps1
Normal 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
113
ad-objects/lib/ADGroup.ps1
Normal 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
|
||||
}
|
||||
15
ad-objects/lib/ADHelper.ps1
Normal file
15
ad-objects/lib/ADHelper.ps1
Normal 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')
|
||||
56
ad-objects/lib/ADOrganizationalUnit.ps1
Normal file
56
ad-objects/lib/ADOrganizationalUnit.ps1
Normal 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' }
|
||||
}
|
||||
}
|
||||
224
ad-objects/lib/ADPasswordPolicy.ps1
Normal file
224
ad-objects/lib/ADPasswordPolicy.ps1
Normal 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
200
ad-objects/lib/ADUser.ps1
Normal 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
32
ad-objects/ous.ps1
Normal 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'
|
||||
}
|
||||
)
|
||||
37
ad-objects/password-policies.ps1
Normal file
37
ad-objects/password-policies.ps1
Normal 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
47
ad-objects/users.ps1
Normal 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
92
gpo/Apply-DSCBaseline.ps1
Normal 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
376
gpo/Apply-GPOBaseline.ps1
Normal 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
135
gpo/Get-UnmanagedGPOs.ps1
Normal 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
|
||||
78
gpo/Restore-GPOBaseline.ps1
Normal file
78
gpo/Restore-GPOBaseline.ps1
Normal 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
41
gpo/admins-01/README.md
Normal 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.
|
||||
98
gpo/admins-01/settings.ps1
Normal file
98
gpo/admins-01/settings.ps1
Normal 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
|
||||
}
|
||||
)
|
||||
}
|
||||
45
gpo/adminworkstations-01/Install-RSAT.ps1
Normal file
45
gpo/adminworkstations-01/Install-RSAT.ps1
Normal 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 ==='
|
||||
111
gpo/adminworkstations-01/README.md
Normal file
111
gpo/adminworkstations-01/README.md
Normal 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 |
|
||||
169
gpo/adminworkstations-01/WDACPolicy.xml
Normal file
169
gpo/adminworkstations-01/WDACPolicy.xml
Normal 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>
|
||||
470
gpo/adminworkstations-01/settings.ps1
Normal file
470
gpo/adminworkstations-01/settings.ps1
Normal 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'
|
||||
}
|
||||
)
|
||||
}
|
||||
141
gpo/default-domain-controller/DefaultDCPolicy.ps1
Normal file
141
gpo/default-domain-controller/DefaultDCPolicy.ps1
Normal 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']
|
||||
}
|
||||
}
|
||||
}
|
||||
154
gpo/default-domain-controller/settings.ps1
Normal file
154
gpo/default-domain-controller/settings.ps1
Normal 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 = @()
|
||||
}
|
||||
118
gpo/default-domain/DefaultDomainPolicy.ps1
Normal file
118
gpo/default-domain/DefaultDomainPolicy.ps1
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
53
gpo/default-domain/settings.ps1
Normal file
53
gpo/default-domain/settings.ps1
Normal 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
272
gpo/lib/GPOAppLocker.ps1
Normal 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
217
gpo/lib/GPOAudit.ps1
Normal 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
214
gpo/lib/GPOBackup.ps1
Normal 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
273
gpo/lib/GPOCore.ps1
Normal 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
303
gpo/lib/GPOFirewall.ps1
Normal 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
|
||||
}
|
||||
215
gpo/lib/GPOFolderRedirection.ps1
Normal file
215
gpo/lib/GPOFolderRedirection.ps1
Normal 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
22
gpo/lib/GPOHelper.ps1
Normal 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
351
gpo/lib/GPOPermissions.ps1
Normal 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
377
gpo/lib/GPOPolicy.ps1
Normal 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
837
gpo/lib/GPOPreferences.ps1
Normal 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
271
gpo/lib/GPOScripts.ps1
Normal 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
181
gpo/lib/GPOWdac.ps1
Normal 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
190
gpo/lib/GPOWmiFilter.ps1
Normal 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
115
gpo/servers-01/README.md
Normal 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
357
gpo/servers-01/settings.ps1
Normal 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
38
gpo/users-01/README.md
Normal 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
133
gpo/users-01/settings.ps1
Normal 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
|
||||
}
|
||||
)
|
||||
}
|
||||
96
gpo/workstations-01/README.md
Normal file
96
gpo/workstations-01/README.md
Normal 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
|
||||
288
gpo/workstations-01/settings.ps1
Normal file
288
gpo/workstations-01/settings.ps1
Normal 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
|
||||
}
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user