1621 lines
52 KiB
Markdown
1621 lines
52 KiB
Markdown
# Advanced PowerShell — L3 / SME Reference
|
|
> Deep-dive techniques for senior engineers and subject matter experts. Assumes solid PowerShell fundamentals.
|
|
> Covers automation architecture, performance, security, internals, and enterprise-scale patterns.
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
1. [Script Architecture & Module Design](#script-architecture--module-design)
|
|
2. [Advanced Functions & Parameter Binding](#advanced-functions--parameter-binding)
|
|
3. [The PowerShell Pipeline — Internals](#the-powershell-pipeline--internals)
|
|
4. [Classes & Object-Oriented PowerShell](#classes--object-oriented-powershell)
|
|
5. [Runspaces & Parallel Execution](#runspaces--parallel-execution)
|
|
6. [Desired State Configuration (DSC)](#desired-state-configuration-dsc)
|
|
7. [Just Enough Administration (JEA)](#just-enough-administration-jea)
|
|
8. [Secrets & Credential Management](#secrets--credential-management)
|
|
9. [PowerShell Remoting — Advanced Patterns](#powershell-remoting--advanced-patterns)
|
|
10. [Logging, Auditing & Transcription](#logging-auditing--transcription)
|
|
11. [Error Handling — Production Patterns](#error-handling--production-patterns)
|
|
12. [Regular Expressions in PowerShell](#regular-expressions-in-powershell)
|
|
13. [Working with APIs & REST](#working-with-apis--rest)
|
|
14. [Working with Databases](#working-with-databases)
|
|
15. [XML, JSON & YAML Manipulation](#xml-json--yaml-manipulation)
|
|
16. [Performance Optimisation](#performance-optimisation)
|
|
17. [Security & Constrained Language Mode](#security--constrained-language-mode)
|
|
18. [Working with .NET Directly](#working-with-net-directly)
|
|
19. [PowerShell Providers & PSDrives](#powershell-providers--psdrives)
|
|
20. [Enterprise Automation Patterns](#enterprise-automation-patterns)
|
|
21. [Debugging & Diagnostics](#debugging--diagnostics)
|
|
22. [CI/CD Integration for PowerShell](#cicd-integration-for-powershell)
|
|
|
|
---
|
|
|
|
## Script Architecture & Module Design
|
|
|
|
### Module Structure (Production Standard)
|
|
```
|
|
MyModule/
|
|
├── MyModule.psd1 # Module manifest (the "front door")
|
|
├── MyModule.psm1 # Root module — loads everything
|
|
├── Public/ # Exported functions (visible to users)
|
|
│ ├── Get-ServerHealth.ps1
|
|
│ └── Set-ServerConfig.ps1
|
|
├── Private/ # Internal helper functions (not exported)
|
|
│ ├── Write-Log.ps1
|
|
│ └── Test-Connectivity.ps1
|
|
├── Classes/ # PS classes (loaded before functions)
|
|
│ └── ServerConfig.ps1
|
|
├── Data/ # Static data, config templates
|
|
│ └── defaults.psd1
|
|
└── Tests/ # Pester tests
|
|
├── Get-ServerHealth.Tests.ps1
|
|
└── Set-ServerConfig.Tests.ps1
|
|
```
|
|
|
|
### Module Manifest (psd1) — Key Fields
|
|
```powershell
|
|
@{
|
|
ModuleVersion = '2.1.0'
|
|
GUID = 'a1b2c3d4-...' # New-Guid to generate
|
|
Author = 'Infra Team'
|
|
Description = 'Enterprise server management module'
|
|
PowerShellVersion = '5.1'
|
|
RootModule = 'MyModule.psm1'
|
|
|
|
# Only export what you intend to be public API
|
|
FunctionsToExport = @('Get-ServerHealth', 'Set-ServerConfig')
|
|
CmdletsToExport = @()
|
|
VariablesToExport = @()
|
|
AliasesToExport = @()
|
|
|
|
# Declare external module dependencies
|
|
RequiredModules = @(
|
|
@{ ModuleName = 'ActiveDirectory'; ModuleVersion = '1.0.0' }
|
|
)
|
|
|
|
PrivateData = @{
|
|
PSData = @{
|
|
Tags = @('Infrastructure', 'Server', 'Automation')
|
|
ProjectUri = 'https://github.com/corp/MyModule'
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Root Module Loader (psm1)
|
|
```powershell
|
|
# Load classes FIRST — functions may depend on them
|
|
foreach ($class in Get-ChildItem "$PSScriptRoot\Classes\*.ps1") {
|
|
. $class.FullName
|
|
}
|
|
|
|
# Load private helpers
|
|
foreach ($private in Get-ChildItem "$PSScriptRoot\Private\*.ps1") {
|
|
. $private.FullName
|
|
}
|
|
|
|
# Load public functions
|
|
foreach ($public in Get-ChildItem "$PSScriptRoot\Public\*.ps1") {
|
|
. $public.FullName
|
|
}
|
|
|
|
# Export only Public functions — Private stay hidden
|
|
Export-ModuleMember -Function (Get-ChildItem "$PSScriptRoot\Public\*.ps1").BaseName
|
|
```
|
|
> **Why this pattern?** It gives you clean separation of public API vs internals, makes Pester testing straightforward, and lets multiple developers work on different functions without merge conflicts on a single large .psm1 file.
|
|
|
|
---
|
|
|
|
## Advanced Functions & Parameter Binding
|
|
|
|
### Full CmdletBinding Template
|
|
```powershell
|
|
function Invoke-ServerMaintenance {
|
|
[CmdletBinding(
|
|
SupportsShouldProcess = $true, # Enables -WhatIf and -Confirm
|
|
ConfirmImpact = 'High', # Triggers confirm prompt by default
|
|
DefaultParameterSetName = 'ByName'
|
|
)]
|
|
param (
|
|
# Parameter sets let one function handle multiple input patterns
|
|
[Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName,
|
|
ParameterSetName = 'ByName', Position = 0)]
|
|
[ValidateNotNullOrEmpty()]
|
|
[string[]]$ComputerName,
|
|
|
|
[Parameter(Mandatory, ParameterSetName = 'BySession')]
|
|
[System.Management.Automation.Runspaces.PSSession[]]$Session,
|
|
|
|
[Parameter()]
|
|
[ValidateSet('Reboot','DrainConnections','RunChecks')]
|
|
[string]$Action = 'RunChecks',
|
|
|
|
[Parameter()]
|
|
[ValidateRange(1, 300)]
|
|
[int]$TimeoutSeconds = 60,
|
|
|
|
[Parameter()]
|
|
[ValidateScript({
|
|
if (Test-Path $_) { $true }
|
|
else { throw "Log path '$_' does not exist." }
|
|
})]
|
|
[string]$LogPath,
|
|
|
|
# Switch params — presence means $true
|
|
[switch]$Force
|
|
)
|
|
|
|
begin {
|
|
# Runs ONCE before pipeline input — initialise, open connections, etc.
|
|
Write-Verbose "[$($MyInvocation.MyCommand)] Starting — Action: $Action"
|
|
$results = [System.Collections.Generic.List[PSObject]]::new()
|
|
}
|
|
|
|
process {
|
|
# Runs ONCE PER PIPELINE OBJECT
|
|
foreach ($computer in $ComputerName) {
|
|
# ShouldProcess check — respects -WhatIf and -Confirm
|
|
if ($PSCmdlet.ShouldProcess($computer, "Perform $Action")) {
|
|
try {
|
|
# ... do work ...
|
|
$results.Add([PSCustomObject]@{
|
|
ComputerName = $computer
|
|
Action = $Action
|
|
Success = $true
|
|
Timestamp = Get-Date
|
|
})
|
|
}
|
|
catch {
|
|
Write-Error "Failed on $computer`: $_"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
end {
|
|
# Runs ONCE after all pipeline input — finalise, close connections, output
|
|
Write-Verbose "[$($MyInvocation.MyCommand)] Completed. Processed $($results.Count) servers."
|
|
$results
|
|
}
|
|
}
|
|
```
|
|
|
|
### Dynamic Parameters
|
|
```powershell
|
|
# Dynamic params are added at runtime based on other parameter values
|
|
function Get-EnvironmentConfig {
|
|
[CmdletBinding()]
|
|
param (
|
|
[ValidateSet('Production','Staging','Dev')]
|
|
[string]$Environment
|
|
)
|
|
|
|
DynamicParam {
|
|
$paramDictionary = [System.Management.Automation.RuntimeDefinedParameterDictionary]::new()
|
|
|
|
if ($Environment -eq 'Production') {
|
|
$attr = [System.Management.Automation.ParameterAttribute]@{ Mandatory = $true }
|
|
$validateSet = [System.Management.Automation.ValidateSetAttribute]::new('FullAudit','ReadOnly')
|
|
$collection = [System.Collections.ObjectModel.Collection[System.Attribute]]::new()
|
|
$collection.Add($attr)
|
|
$collection.Add($validateSet)
|
|
|
|
$dynParam = [System.Management.Automation.RuntimeDefinedParameter]::new(
|
|
'AccessMode', [string], $collection
|
|
)
|
|
$paramDictionary.Add('AccessMode', $dynParam)
|
|
}
|
|
return $paramDictionary
|
|
}
|
|
|
|
process {
|
|
$accessMode = $PSBoundParameters['AccessMode']
|
|
Write-Output "Environment: $Environment | AccessMode: $accessMode"
|
|
}
|
|
}
|
|
```
|
|
|
|
### Parameter Validation Attributes (Full List)
|
|
```powershell
|
|
[ValidateNotNull()] # Not $null
|
|
[ValidateNotNullOrEmpty()] # Not $null or ""
|
|
[ValidateSet('A','B','C')] # Must be one of these values
|
|
[ValidateRange(1, 100)] # Numeric range
|
|
[ValidateLength(3, 50)] # String length range
|
|
[ValidatePattern('^[A-Z]{3}\d{4}$')] # Must match regex
|
|
[ValidateCount(1, 10)] # Array element count
|
|
[ValidateScript({ $_ -gt 0 })] # Custom validation logic
|
|
[AllowNull()] # Explicitly permit $null
|
|
[AllowEmptyString()] # Explicitly permit ""
|
|
[AllowEmptyCollection()] # Explicitly permit @()
|
|
```
|
|
|
|
---
|
|
|
|
## The PowerShell Pipeline — Internals
|
|
|
|
### How the Pipeline Actually Works
|
|
```powershell
|
|
# The pipeline streams objects one at a time — it does NOT buffer everything first.
|
|
# This is crucial for memory efficiency with large datasets.
|
|
|
|
# BAD — loads ALL 100k objects into memory, then sorts
|
|
Get-Content "C:\Logs\huge.log" | Sort-Object # OOM risk on large files
|
|
|
|
# GOOD — stream-process without full load into memory
|
|
Get-Content "C:\Logs\huge.log" | ForEach-Object {
|
|
if ($_ -match "ERROR") { $_ }
|
|
}
|
|
```
|
|
|
|
### WriteObject vs Return in the Pipeline
|
|
```powershell
|
|
function Get-Items {
|
|
process {
|
|
# $PSCmdlet.WriteObject() sends to pipeline WITHOUT collecting into array
|
|
# This is what PowerShell does internally when you just output an object
|
|
foreach ($i in 1..5) {
|
|
[PSCustomObject]@{ Id = $i; Value = $i * 2 }
|
|
# ^ Each object hits the pipeline immediately — next cmdlet can process it
|
|
}
|
|
|
|
# Using 'return' or wrapping in @() forces array buffering — avoid in hot loops
|
|
}
|
|
}
|
|
```
|
|
|
|
### SteppablePipeline — Proxy Functions
|
|
```powershell
|
|
# Wrap an existing cmdlet to intercept/modify its pipeline behaviour
|
|
function Get-Process {
|
|
[CmdletBinding()]
|
|
param([string]$Name)
|
|
|
|
begin {
|
|
# Get metadata for the real Get-Process
|
|
$wrappedCmd = $ExecutionContext.InvokeCommand.GetCommand(
|
|
'Microsoft.PowerShell.Management\Get-Process', [System.Management.Automation.CommandTypes]::Cmdlet
|
|
)
|
|
$scriptCmd = { & $wrappedCmd @PSBoundParameters }
|
|
$steppable = $scriptCmd.GetSteppablePipeline($myInvocation.CommandOrigin)
|
|
$steppable.Begin($PSCmdlet)
|
|
}
|
|
process {
|
|
# Intercept — add custom logic here before/after forwarding
|
|
$steppable.Process($_)
|
|
}
|
|
end {
|
|
$steppable.End()
|
|
}
|
|
}
|
|
```
|
|
> Proxy functions are powerful for **adding logging, telemetry, or parameter defaults** to built-in cmdlets without breaking existing scripts that call them.
|
|
|
|
---
|
|
|
|
## Classes & Object-Oriented PowerShell
|
|
|
|
### Full Class Pattern
|
|
```powershell
|
|
# Classes must be defined BEFORE they are used (dot-source or load in begin{})
|
|
|
|
class ServerConfig {
|
|
# Properties with types
|
|
[string] $Hostname
|
|
[string] $Environment
|
|
[int] $MaxConnections
|
|
[bool] $IsProduction
|
|
[datetime] $LastChecked
|
|
|
|
# Hidden property — not shown in default output
|
|
hidden [string] $_internalId
|
|
|
|
# Static property — shared across all instances
|
|
static [string] $DefaultEnvironment = 'Development'
|
|
|
|
# Constructor
|
|
ServerConfig([string]$hostname, [string]$environment) {
|
|
$this.Hostname = $hostname
|
|
$this.Environment = $environment
|
|
$this.MaxConnections = 100
|
|
$this.IsProduction = ($environment -eq 'Production')
|
|
$this.LastChecked = Get-Date
|
|
$this._internalId = [System.Guid]::NewGuid().ToString()
|
|
}
|
|
|
|
# Default constructor
|
|
ServerConfig() : base() {
|
|
$this.Environment = [ServerConfig]::DefaultEnvironment
|
|
}
|
|
|
|
# Method
|
|
[bool] IsHealthy() {
|
|
return (Test-Connection $this.Hostname -Count 1 -Quiet)
|
|
}
|
|
|
|
# Static method
|
|
static [ServerConfig] FromHashtable([hashtable]$data) {
|
|
$config = [ServerConfig]::new($data.Hostname, $data.Environment)
|
|
$config.MaxConnections = $data.MaxConnections ?? 100
|
|
return $config
|
|
}
|
|
|
|
# Override ToString for readable output
|
|
[string] ToString() {
|
|
return "[$($this.Environment)] $($this.Hostname)"
|
|
}
|
|
}
|
|
|
|
# Inheritance
|
|
class WebServerConfig : ServerConfig {
|
|
[int] $HttpPort = 80
|
|
[int] $HttpsPort = 443
|
|
[string] $SiteName
|
|
|
|
WebServerConfig([string]$hostname, [string]$siteName) : base($hostname, 'Production') {
|
|
$this.SiteName = $siteName
|
|
}
|
|
|
|
[bool] IsSslConfigured() {
|
|
return (Test-NetConnection $this.Hostname -Port $this.HttpsPort -WarningAction SilentlyContinue).TcpTestSucceeded
|
|
}
|
|
}
|
|
|
|
# Usage
|
|
$srv = [ServerConfig]::new('SERVER01', 'Production')
|
|
$web = [WebServerConfig]::new('WEB01', 'MainSite')
|
|
$web.IsHealthy()
|
|
$web.IsSslConfigured()
|
|
```
|
|
|
|
### Implementing Interfaces via Classes
|
|
```powershell
|
|
# PowerShell classes can implement .NET interfaces
|
|
class LogEntry : System.IComparable {
|
|
[datetime] $Timestamp
|
|
[string] $Message
|
|
[string] $Severity
|
|
|
|
LogEntry([string]$message, [string]$severity) {
|
|
$this.Timestamp = Get-Date
|
|
$this.Message = $message
|
|
$this.Severity = $severity
|
|
}
|
|
|
|
# Required by IComparable — enables Sort-Object on collections
|
|
[int] CompareTo([object]$other) {
|
|
return $this.Timestamp.CompareTo(([LogEntry]$other).Timestamp)
|
|
}
|
|
}
|
|
|
|
$entries = @(
|
|
[LogEntry]::new("System started", "INFO")
|
|
[LogEntry]::new("Disk low", "WARN")
|
|
)
|
|
$entries | Sort-Object # Now works correctly via IComparable
|
|
```
|
|
|
|
---
|
|
|
|
## Runspaces & Parallel Execution
|
|
|
|
### ForEach-Object -Parallel (PowerShell 7+)
|
|
```powershell
|
|
# Clean and simple for most parallel work
|
|
$servers = @("SERVER01","SERVER02","SERVER03","SERVER04","SERVER05")
|
|
|
|
$results = $servers | ForEach-Object -Parallel {
|
|
$server = $_
|
|
$ping = Test-Connection $server -Count 1 -Quiet
|
|
[PSCustomObject]@{
|
|
Server = $server
|
|
Online = $ping
|
|
Time = Get-Date
|
|
}
|
|
} -ThrottleLimit 10 # Max concurrent threads (default 5)
|
|
|
|
# Pass variables into parallel scope with $using:
|
|
$timeout = 30
|
|
$servers | ForEach-Object -Parallel {
|
|
Test-NetConnection $_ -Port 443 -InformationLevel Quiet -WarningAction SilentlyContinue
|
|
# Access outer scope variable
|
|
Start-Sleep -Milliseconds $using:timeout
|
|
} -ThrottleLimit 5
|
|
```
|
|
|
|
### Runspace Pools (PowerShell 5.1 Compatible)
|
|
```powershell
|
|
# More control, works on PS 5.1 — needed in enterprise environments
|
|
$scriptBlock = {
|
|
param($Server, $Timeout)
|
|
try {
|
|
$result = Test-Connection $Server -Count 1 -Quiet -ErrorAction Stop
|
|
[PSCustomObject]@{ Server = $Server; Online = $result; Error = $null }
|
|
}
|
|
catch {
|
|
[PSCustomObject]@{ Server = $Server; Online = $false; Error = $_.Exception.Message }
|
|
}
|
|
}
|
|
|
|
$servers = @("SERVER01","SERVER02","SERVER03","SERVER04","SERVER05")
|
|
$maxThreads = 10
|
|
|
|
# Create and open the runspace pool
|
|
$pool = [RunspaceFactory]::CreateRunspacePool(1, $maxThreads)
|
|
$pool.Open()
|
|
|
|
# Spin up all jobs
|
|
$jobs = foreach ($server in $servers) {
|
|
$ps = [PowerShell]::Create()
|
|
$ps.RunspacePool = $pool
|
|
[void]$ps.AddScript($scriptBlock)
|
|
[void]$ps.AddArgument($server)
|
|
[void]$ps.AddArgument(30)
|
|
[PSCustomObject]@{
|
|
PowerShell = $ps
|
|
Handle = $ps.BeginInvoke()
|
|
Server = $server
|
|
}
|
|
}
|
|
|
|
# Collect results as they complete
|
|
$results = foreach ($job in $jobs) {
|
|
$job.PowerShell.EndInvoke($job.Handle)
|
|
$job.PowerShell.Dispose()
|
|
}
|
|
|
|
$pool.Close()
|
|
$pool.Dispose()
|
|
|
|
$results | Format-Table -AutoSize
|
|
```
|
|
> Runspace pools are the **gold standard for high-performance parallel work in PS 5.1**. Unlike Start-Job (which spawns new processes), runspaces share the same process — much lower overhead. Ideal for scanning hundreds of servers simultaneously.
|
|
|
|
### Thread-Safe Collections for Parallel Work
|
|
```powershell
|
|
# Regular arrays/lists are NOT thread-safe — use these in parallel scenarios
|
|
$bag = [System.Collections.Concurrent.ConcurrentBag[PSObject]]::new()
|
|
$queue = [System.Collections.Concurrent.ConcurrentQueue[string]]::new()
|
|
$dict = [System.Collections.Concurrent.ConcurrentDictionary[string,object]]::new()
|
|
|
|
# Example: thread-safe result collection
|
|
$servers | ForEach-Object -Parallel {
|
|
$results = $using:bag
|
|
$results.Add([PSCustomObject]@{ Server = $_; Time = Get-Date })
|
|
} -ThrottleLimit 10
|
|
```
|
|
|
|
---
|
|
|
|
## Desired State Configuration (DSC)
|
|
|
|
### DSC Configuration — Real-World Example
|
|
```powershell
|
|
Configuration WebServerBaseline {
|
|
param (
|
|
[string[]]$ComputerName = 'localhost',
|
|
[PSCredential]$Credential
|
|
)
|
|
|
|
Import-DscResource -ModuleName PSDesiredStateConfiguration
|
|
Import-DscResource -ModuleName xWebAdministration
|
|
|
|
Node $ComputerName {
|
|
|
|
# Ensure IIS is installed
|
|
WindowsFeature IIS {
|
|
Ensure = 'Present'
|
|
Name = 'Web-Server'
|
|
}
|
|
|
|
WindowsFeature IISManagement {
|
|
Ensure = 'Present'
|
|
Name = 'Web-Mgmt-Tools'
|
|
DependsOn = '[WindowsFeature]IIS'
|
|
}
|
|
|
|
# Ensure a service is running
|
|
Service W3SVC {
|
|
Name = 'W3SVC'
|
|
State = 'Running'
|
|
StartupType = 'Automatic'
|
|
DependsOn = '[WindowsFeature]IIS'
|
|
}
|
|
|
|
# Ensure a file exists with specific content
|
|
File AppConfig {
|
|
Ensure = 'Present'
|
|
DestinationPath = 'C:\inetpub\wwwroot\app.config'
|
|
Contents = '<configuration><appSettings /></configuration>'
|
|
Type = 'File'
|
|
}
|
|
|
|
# Registry setting
|
|
Registry TLS12 {
|
|
Ensure = 'Present'
|
|
Key = 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server'
|
|
ValueName = 'Enabled'
|
|
ValueData = '1'
|
|
ValueType = 'Dword'
|
|
}
|
|
}
|
|
}
|
|
|
|
# Compile to MOF
|
|
WebServerBaseline -ComputerName SERVER01, SERVER02 -OutputPath C:\DSC\WebBaseline
|
|
|
|
# Apply — push mode
|
|
Start-DscConfiguration -Path C:\DSC\WebBaseline -ComputerName SERVER01 -Wait -Verbose -Force
|
|
|
|
# Test compliance without changing anything
|
|
Test-DscConfiguration -ComputerName SERVER01
|
|
|
|
# Get current state vs desired state
|
|
Get-DscConfiguration -CimSession (New-CimSession SERVER01)
|
|
Get-DscConfigurationStatus -CimSession (New-CimSession SERVER01)
|
|
```
|
|
|
|
---
|
|
|
|
## Just Enough Administration (JEA)
|
|
|
|
> JEA creates **constrained PowerShell endpoints** that let users run specific commands with elevated privileges — without giving them full admin rights or interactive sessions.
|
|
|
|
### Role Capability File
|
|
```powershell
|
|
# Create the role capability file template
|
|
New-PSRoleCapabilityFile -Path "C:\JEA\Roles\HelpDesk.psrc"
|
|
```
|
|
|
|
```powershell
|
|
# HelpDesk.psrc — what this role is allowed to do
|
|
@{
|
|
# Visible cmdlets — everything else is hidden
|
|
VisibleCmdlets = @(
|
|
'Get-Service',
|
|
'Get-Process',
|
|
'Get-EventLog',
|
|
@{ Name = 'Restart-Service'; Parameters = @{ Name = 'Name'; ValidateSet = 'Spooler','W3SVC','WinRM' } },
|
|
@{ Name = 'Stop-Process'; Parameters = @{ Name = 'Name'; ValidatePattern = '^(notepad|calc)$' } }
|
|
)
|
|
|
|
# Functions you define below that are available
|
|
VisibleFunctions = @('Get-ServerSummary')
|
|
|
|
# External executables permitted
|
|
VisibleExternalCommands = @('C:\Windows\System32\ipconfig.exe')
|
|
|
|
# PowerShell providers visible (restrict to FileSystem only if needed)
|
|
VisibleProviders = @('FileSystem', 'Registry')
|
|
|
|
# Define helper functions inline
|
|
FunctionDefinitions = @(
|
|
@{
|
|
Name = 'Get-ServerSummary'
|
|
ScriptBlock = {
|
|
[PSCustomObject]@{
|
|
Hostname = $env:COMPUTERNAME
|
|
Uptime = (Get-Date) - (gcim Win32_OperatingSystem).LastBootUpTime
|
|
TopCPU = Get-Process | Sort-Object CPU -Descending | Select-Object -First 3 Name, CPU
|
|
}
|
|
}
|
|
}
|
|
)
|
|
}
|
|
```
|
|
|
|
### Session Configuration File
|
|
```powershell
|
|
# Create the session config
|
|
New-PSSessionConfigurationFile -Path "C:\JEA\HelpDeskSession.pssc" `
|
|
-SessionType RestrictedRemoteServer `
|
|
-RunAsVirtualAccount `
|
|
-RoleDefinitions @{
|
|
'DOMAIN\HelpDesk' = @{ RoleCapabilities = 'HelpDesk' }
|
|
'DOMAIN\L2Support' = @{ RoleCapabilities = 'HelpDesk', 'NetworkAdmin' }
|
|
} `
|
|
-TranscriptDirectory 'C:\JEA\Transcripts' `
|
|
-LanguageMode ConstrainedLanguage
|
|
|
|
# Register the endpoint on each managed server
|
|
Register-PSSessionConfiguration -Name 'HelpDeskEndpoint' `
|
|
-Path 'C:\JEA\HelpDeskSession.pssc' -Force
|
|
|
|
# Test it — connect as a regular user
|
|
Enter-PSSession -ComputerName SERVER01 -ConfigurationName HelpDeskEndpoint
|
|
|
|
# Audit what a user can do at an endpoint
|
|
$session = New-PSSession -ComputerName SERVER01 -ConfigurationName HelpDeskEndpoint
|
|
Invoke-Command -Session $session -ScriptBlock { Get-Command }
|
|
```
|
|
|
|
---
|
|
|
|
## Secrets & Credential Management
|
|
|
|
### SecretManagement Module (Modern Standard)
|
|
```powershell
|
|
# Install
|
|
Install-Module Microsoft.PowerShell.SecretManagement
|
|
Install-Module Microsoft.PowerShell.SecretStore # Local encrypted vault
|
|
|
|
# Register a vault
|
|
Register-SecretVault -Name 'CorpVault' -ModuleName 'Microsoft.PowerShell.SecretStore'
|
|
|
|
# Store secrets
|
|
Set-Secret -Name 'SQLServiceAccount' -Secret (Get-Credential)
|
|
Set-Secret -Name 'APIToken' -Secret 'abc123supersecret'
|
|
|
|
# Retrieve in scripts — no plain text, no hard-coding
|
|
$cred = Get-Secret -Name 'SQLServiceAccount'
|
|
$apiToken = Get-Secret -Name 'APIToken' -AsPlainText
|
|
|
|
# List all secrets (names only — values stay encrypted)
|
|
Get-SecretInfo -Vault 'CorpVault'
|
|
```
|
|
|
|
### Encrypting Strings for Script Storage
|
|
```powershell
|
|
# Encrypt — only the SAME USER on the SAME MACHINE can decrypt (DPAPI)
|
|
$secureString = Read-Host "Enter secret" -AsSecureString
|
|
$encrypted = ConvertFrom-SecureString $secureString
|
|
$encrypted | Out-File "C:\Scripts\.secret" # Store encrypted string
|
|
|
|
# Decrypt at runtime
|
|
$encrypted = Get-Content "C:\Scripts\.secret"
|
|
$secure = ConvertTo-SecureString $encrypted
|
|
$credential = New-Object System.Management.Automation.PSCredential("svcAccount", $secure)
|
|
|
|
# For cross-machine/service-account use — AES key approach
|
|
$key = (1..32 | ForEach-Object { Get-Random -Minimum 0 -Maximum 256 })
|
|
$key | Export-Clixml "C:\Secure\aes.key" # Protect this file with NTFS permissions!
|
|
|
|
$encrypted = ConvertFrom-SecureString $secureString -Key $key
|
|
$decrypted = ConvertTo-SecureString $encrypted -Key (Import-Clixml "C:\Secure\aes.key")
|
|
```
|
|
> **Never** store plain-text passwords in scripts. Use SecretManagement for modern deployments, or DPAPI/AES encryption as a fallback. Protect AES key files with strict NTFS ACLs.
|
|
|
|
---
|
|
|
|
## PowerShell Remoting — Advanced Patterns
|
|
|
|
### ConstrainedLanguage Remoting & Custom Endpoints
|
|
```powershell
|
|
# Create a custom endpoint with specific module access
|
|
Register-PSSessionConfiguration -Name 'InfraTools' `
|
|
-StartupScript 'C:\Scripts\LoadInfraModule.ps1' `
|
|
-RunAsCredential 'DOMAIN\svcInfraTools' `
|
|
-AccessMode Remote `
|
|
-Force
|
|
|
|
# Connect to a named endpoint
|
|
Enter-PSSession -ComputerName SERVER01 -ConfigurationName 'InfraTools'
|
|
```
|
|
|
|
### SSH Tunnelling for Remoting
|
|
```powershell
|
|
# Forward local port 15985 to remote server's WinRM port via SSH jump host
|
|
# Run in a separate terminal / background job
|
|
ssh -L 15985:TARGET_SERVER:5985 jumphost.corp.com -N
|
|
|
|
# Now connect through the tunnel
|
|
$session = New-PSSession -ComputerName localhost -Port 15985 -Credential (Get-Credential)
|
|
Invoke-Command -Session $session -ScriptBlock { hostname }
|
|
```
|
|
|
|
### Fan-Out Pattern — Run Against Many Servers Efficiently
|
|
```powershell
|
|
function Invoke-ParallelRemote {
|
|
param (
|
|
[string[]]$ComputerName,
|
|
[scriptblock]$ScriptBlock,
|
|
[PSCredential]$Credential,
|
|
[int]$BatchSize = 50,
|
|
[int]$ThrottleLimit = 32
|
|
)
|
|
|
|
# Process in batches to avoid overwhelming WinRM
|
|
$batches = for ($i = 0; $i -lt $ComputerName.Count; $i += $BatchSize) {
|
|
, ($ComputerName[$i..([Math]::Min($i + $BatchSize - 1, $ComputerName.Count - 1))])
|
|
}
|
|
|
|
foreach ($batch in $batches) {
|
|
$params = @{
|
|
ComputerName = $batch
|
|
ScriptBlock = $ScriptBlock
|
|
ThrottleLimit = $ThrottleLimit
|
|
ErrorAction = 'SilentlyContinue'
|
|
}
|
|
if ($Credential) { $params['Credential'] = $Credential }
|
|
|
|
Invoke-Command @params
|
|
}
|
|
}
|
|
|
|
# Usage — hit 500 servers without melting WinRM
|
|
$allServers = Get-ADComputer -Filter {OperatingSystem -like "*Server*"} | Select-Object -Expand Name
|
|
Invoke-ParallelRemote -ComputerName $allServers -ScriptBlock {
|
|
[PSCustomObject]@{
|
|
Server = $env:COMPUTERNAME
|
|
FreeGB = [math]::Round((Get-PSDrive C).Free / 1GB, 1)
|
|
Uptime = ((Get-Date) - (gcim Win32_OperatingSystem).LastBootUpTime).Days
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Logging, Auditing & Transcription
|
|
|
|
### Structured Logging Function
|
|
```powershell
|
|
enum LogLevel { DEBUG; INFO; WARN; ERROR; FATAL }
|
|
|
|
function Write-StructuredLog {
|
|
[CmdletBinding()]
|
|
param (
|
|
[Parameter(Mandatory)][string]$Message,
|
|
[LogLevel]$Level = [LogLevel]::INFO,
|
|
[string]$LogFile = "C:\Logs\$($MyInvocation.ScriptName -replace '.*\\').log",
|
|
[hashtable]$Properties = @{}
|
|
)
|
|
|
|
$entry = [ordered]@{
|
|
Timestamp = (Get-Date).ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
|
|
Level = $Level.ToString()
|
|
Host = $env:COMPUTERNAME
|
|
User = $env:USERNAME
|
|
PID = $PID
|
|
Message = $Message
|
|
}
|
|
$Properties.GetEnumerator() | ForEach-Object { $entry[$_.Key] = $_.Value }
|
|
|
|
# JSON to log file — machine-parseable by Splunk, ELK, etc.
|
|
$entry | ConvertTo-Json -Compress | Add-Content $LogFile
|
|
|
|
# Also write to console at appropriate stream
|
|
switch ($Level) {
|
|
'DEBUG' { Write-Debug $Message }
|
|
'INFO' { Write-Verbose $Message }
|
|
'WARN' { Write-Warning $Message }
|
|
'ERROR' { Write-Error $Message }
|
|
'FATAL' { Write-Error $Message; throw $Message }
|
|
}
|
|
}
|
|
|
|
# Usage
|
|
Write-StructuredLog "Starting deployment" -Level INFO -Properties @{ Server = "WEB01"; Version = "2.1" }
|
|
Write-StructuredLog "Disk space low" -Level WARN -Properties @{ FreeGB = 2.1; Threshold = 5 }
|
|
```
|
|
|
|
### Enable PowerShell Script Block Logging (Group Policy / Registry)
|
|
```powershell
|
|
# Enable on a target server — logs ALL executed script blocks to Event Log
|
|
Invoke-Command -ComputerName SERVER01 -ScriptBlock {
|
|
$path = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging'
|
|
New-Item $path -Force | Out-Null
|
|
Set-ItemProperty $path -Name EnableScriptBlockLogging -Value 1
|
|
Set-ItemProperty $path -Name EnableScriptBlockInvocationLogging -Value 1
|
|
}
|
|
|
|
# Read script block logs (Event ID 4104)
|
|
Get-WinEvent -ComputerName SERVER01 -FilterHashtable @{
|
|
LogName = 'Microsoft-Windows-PowerShell/Operational'
|
|
Id = 4104
|
|
} | Select-Object TimeCreated, @{N="Script";E={$_.Properties[2].Value}} | Select-Object -First 20
|
|
```
|
|
|
|
### Transcript Logging
|
|
```powershell
|
|
# Start transcript — logs all input and output
|
|
Start-Transcript -Path "C:\Logs\session-$(Get-Date -Format yyyyMMdd-HHmmss).log" -Append
|
|
|
|
# Configure automatic transcription via Group Policy or registry
|
|
$path = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription'
|
|
New-Item $path -Force | Out-Null
|
|
Set-ItemProperty $path -Name EnableTranscripting -Value 1
|
|
Set-ItemProperty $path -Name EnableInvocationHeader -Value 1
|
|
Set-ItemProperty $path -Name OutputDirectory -Value '\\LOGSERVER\PSTranscripts$'
|
|
```
|
|
|
|
---
|
|
|
|
## Error Handling — Production Patterns
|
|
|
|
### Hierarchical Error Handling
|
|
```powershell
|
|
function Invoke-SafeOperation {
|
|
[CmdletBinding()]
|
|
param([string]$ComputerName, [scriptblock]$Operation)
|
|
|
|
$ErrorActionPreference = 'Stop' # Make ALL errors terminating within function
|
|
|
|
try {
|
|
Invoke-Command -ComputerName $ComputerName -ScriptBlock $Operation
|
|
}
|
|
catch [System.Management.Automation.Remoting.PSRemotingTransportException] {
|
|
# Specific catch for WinRM/connectivity failures
|
|
Write-StructuredLog "Cannot reach $ComputerName — WinRM transport failure" -Level WARN
|
|
Write-Error "Transport failure: $($_.Exception.Message)" -ErrorAction Continue
|
|
}
|
|
catch [System.UnauthorizedAccessException] {
|
|
Write-StructuredLog "Access denied to $ComputerName" -Level ERROR
|
|
throw # Re-throw — caller needs to know
|
|
}
|
|
catch {
|
|
# Capture full error context for diagnostics
|
|
$errorDetail = [PSCustomObject]@{
|
|
Message = $_.Exception.Message
|
|
Type = $_.Exception.GetType().FullName
|
|
ScriptLine = $_.InvocationInfo.ScriptLineNumber
|
|
Command = $_.InvocationInfo.MyCommand
|
|
StackTrace = $_.ScriptStackTrace
|
|
}
|
|
Write-StructuredLog "Unhandled error on $ComputerName" -Level ERROR -Properties @{
|
|
ErrorType = $errorDetail.Type
|
|
ErrorMsg = $errorDetail.Message
|
|
}
|
|
throw
|
|
}
|
|
finally {
|
|
# Always runs — cleanup, close connections, release locks
|
|
Write-Verbose "Operation on $ComputerName completed (success or fail)"
|
|
}
|
|
}
|
|
```
|
|
|
|
### $ErrorActionPreference vs -ErrorAction
|
|
```powershell
|
|
# $ErrorActionPreference = global default for the scope
|
|
$ErrorActionPreference = 'Stop' # All errors terminating in this scope
|
|
|
|
# -ErrorAction = per-cmdlet override
|
|
Get-Item "badpath" -ErrorAction SilentlyContinue # Suppress THIS error only
|
|
|
|
# -ErrorVariable = capture errors without stopping
|
|
Get-Item "badpath" -ErrorAction SilentlyContinue -ErrorVariable capturedErr
|
|
if ($capturedErr) { Write-Warning "Item not found: $capturedErr" }
|
|
|
|
# $? = boolean success of last command
|
|
Get-Service "FakeService" -ErrorAction SilentlyContinue
|
|
if (-not $?) { Write-Warning "Last command failed" }
|
|
```
|
|
|
|
---
|
|
|
|
## Regular Expressions in PowerShell
|
|
|
|
```powershell
|
|
# -match operator — sets $Matches automatic variable
|
|
"Server: WEB01 IP: 10.0.0.50" -match 'IP:\s+(\d{1,3}(?:\.\d{1,3}){3})'
|
|
$Matches[0] # Full match: "IP: 10.0.0.50"
|
|
$Matches[1] # Capture group 1: "10.0.0.50"
|
|
|
|
# Named capture groups — much cleaner than numbered groups
|
|
"EventID: 4625 Source: Security" -match 'EventID:\s+(?<id>\d+)\s+Source:\s+(?<source>\w+)'
|
|
$Matches['id'] # "4625"
|
|
$Matches['source'] # "Security"
|
|
|
|
# -replace with regex
|
|
"Server_PROD_WEB01" -replace '_(?<env>PROD|DEV|UAT)_', ' [$env:env] '
|
|
|
|
# Select-String — regex on file content (like grep)
|
|
Select-String -Path "C:\Logs\*.log" -Pattern "ERROR|FATAL" -CaseSensitive
|
|
Select-String -Path "C:\Logs\app.log" -Pattern '(?<date>\d{4}-\d{2}-\d{2}).*ERROR' |
|
|
ForEach-Object { $_.Matches[0].Groups['date'].Value }
|
|
|
|
# [regex] class — when you need full regex engine control
|
|
$regex = [regex]::new('(?<ip>\d{1,3}(?:\.\d{1,3}){3})', 'IgnoreCase,Compiled')
|
|
$content = Get-Content "C:\Logs\firewall.log" -Raw
|
|
$regex.Matches($content) | ForEach-Object { $_.Groups['ip'].Value } | Sort-Object -Unique
|
|
|
|
# Replace with a MatchEvaluator (transform each match)
|
|
$result = [regex]::Replace("server01,server02,server03", '\b\w+\b', {
|
|
param($match)
|
|
$match.Value.ToUpper()
|
|
})
|
|
# Result: "SERVER01,SERVER02,SERVER03"
|
|
```
|
|
|
|
---
|
|
|
|
## Working with APIs & REST
|
|
|
|
### Invoke-RestMethod — Full Pattern
|
|
```powershell
|
|
# Build reusable API client function
|
|
function Invoke-CorpAPI {
|
|
[CmdletBinding()]
|
|
param (
|
|
[string]$Endpoint,
|
|
[string]$Method = 'GET',
|
|
[hashtable]$Body = @{},
|
|
[string]$BaseUri = 'https://api.corp.com/v2'
|
|
)
|
|
|
|
# Retrieve token from SecretManagement
|
|
$token = Get-Secret -Name 'CorpAPIToken' -AsPlainText
|
|
|
|
$params = @{
|
|
Uri = "$BaseUri/$Endpoint"
|
|
Method = $Method
|
|
Headers = @{
|
|
'Authorization' = "Bearer $token"
|
|
'Content-Type' = 'application/json'
|
|
'X-Request-ID' = [System.Guid]::NewGuid().ToString()
|
|
}
|
|
UseBasicParsing = $true
|
|
ErrorAction = 'Stop'
|
|
}
|
|
|
|
if ($Method -in 'POST','PUT','PATCH') {
|
|
$params['Body'] = ($Body | ConvertTo-Json -Depth 10)
|
|
}
|
|
|
|
try {
|
|
$response = Invoke-RestMethod @params
|
|
return $response
|
|
}
|
|
catch {
|
|
$statusCode = $_.Exception.Response.StatusCode.Value__
|
|
$detail = $_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction SilentlyContinue
|
|
throw "API error $statusCode on $Endpoint`: $($detail.message ?? $_.Exception.Message)"
|
|
}
|
|
}
|
|
|
|
# Pagination pattern
|
|
function Get-AllPages {
|
|
param([string]$Endpoint)
|
|
$page = 1
|
|
$results = [System.Collections.Generic.List[PSObject]]::new()
|
|
do {
|
|
$response = Invoke-CorpAPI -Endpoint "$Endpoint`?page=$page&per_page=100"
|
|
$results.AddRange($response.items)
|
|
$page++
|
|
} while ($response.has_more)
|
|
return $results
|
|
}
|
|
```
|
|
|
|
### Working with Graph API (Microsoft 365 / Azure AD)
|
|
```powershell
|
|
# Authenticate with client credentials (app registration)
|
|
function Get-GraphToken {
|
|
param($TenantId, $ClientId, $ClientSecret)
|
|
|
|
$body = @{
|
|
grant_type = 'client_credentials'
|
|
client_id = $ClientId
|
|
client_secret = $ClientSecret
|
|
scope = 'https://graph.microsoft.com/.default'
|
|
}
|
|
$response = Invoke-RestMethod "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" `
|
|
-Method POST -Body $body
|
|
return $response.access_token
|
|
}
|
|
|
|
$token = Get-GraphToken -TenantId $tid -ClientId $cid -ClientSecret $secret
|
|
$headers = @{ Authorization = "Bearer $token" }
|
|
|
|
# Get all users with specific properties
|
|
$users = Invoke-RestMethod `
|
|
"https://graph.microsoft.com/v1.0/users?`$select=displayName,mail,jobTitle&`$top=999" `
|
|
-Headers $headers
|
|
|
|
# Handle nextLink pagination
|
|
$allUsers = [System.Collections.Generic.List[PSObject]]::new()
|
|
$uri = "https://graph.microsoft.com/v1.0/users?`$top=999"
|
|
do {
|
|
$page = Invoke-RestMethod $uri -Headers $headers
|
|
$allUsers.AddRange($page.value)
|
|
$uri = $page.'@odata.nextLink'
|
|
} while ($uri)
|
|
```
|
|
|
|
---
|
|
|
|
## Working with Databases
|
|
|
|
### SQL Server — System.Data.SqlClient
|
|
```powershell
|
|
function Invoke-SqlQuery {
|
|
param (
|
|
[string]$ServerInstance,
|
|
[string]$Database,
|
|
[string]$Query,
|
|
[hashtable]$Parameters = @{},
|
|
[PSCredential]$Credential
|
|
)
|
|
|
|
$connString = if ($Credential) {
|
|
"Server=$ServerInstance;Database=$Database;User Id=$($Credential.UserName);Password=$($Credential.GetNetworkCredential().Password);"
|
|
} else {
|
|
"Server=$ServerInstance;Database=$Database;Integrated Security=SSPI;TrustServerCertificate=True;"
|
|
}
|
|
|
|
$conn = [System.Data.SqlClient.SqlConnection]::new($connString)
|
|
$conn.Open()
|
|
|
|
try {
|
|
$cmd = $conn.CreateCommand()
|
|
$cmd.CommandText = $Query
|
|
$cmd.CommandTimeout = 60
|
|
|
|
# Parameterised query — prevents SQL injection
|
|
foreach ($param in $Parameters.GetEnumerator()) {
|
|
$cmd.Parameters.AddWithValue("@$($param.Key)", $param.Value) | Out-Null
|
|
}
|
|
|
|
$adapter = [System.Data.SqlClient.SqlDataAdapter]::new($cmd)
|
|
$dataset = [System.Data.DataSet]::new()
|
|
$adapter.Fill($dataset) | Out-Null
|
|
return $dataset.Tables[0]
|
|
}
|
|
finally {
|
|
$conn.Close()
|
|
$conn.Dispose()
|
|
}
|
|
}
|
|
|
|
# Usage
|
|
$results = Invoke-SqlQuery -ServerInstance "SQL01\PROD" -Database "CMDB" `
|
|
-Query "SELECT ServerName, Environment, Owner FROM Servers WHERE Environment = @env" `
|
|
-Parameters @{ env = 'Production' }
|
|
|
|
$results | Format-Table
|
|
```
|
|
|
|
---
|
|
|
|
## XML, JSON & YAML Manipulation
|
|
|
|
### XML — Full Manipulation
|
|
```powershell
|
|
# Load XML
|
|
[xml]$config = Get-Content "C:\Config\app.config"
|
|
|
|
# Navigate with dot notation
|
|
$config.configuration.appSettings.add | Where-Object { $_.key -eq "DBServer" }
|
|
|
|
# XPath queries — much more powerful for complex queries
|
|
$nodes = $config.SelectNodes("//appSettings/add[@key='DBServer']")
|
|
|
|
# Modify and save
|
|
$node = $config.SelectSingleNode("//appSettings/add[@key='DBServer']")
|
|
$node.SetAttribute("value", "NEWSQL01")
|
|
$config.Save("C:\Config\app.config")
|
|
|
|
# Create XML from scratch
|
|
$xml = [System.Xml.XmlDocument]::new()
|
|
$root = $xml.CreateElement("Servers")
|
|
$xml.AppendChild($root) | Out-Null
|
|
|
|
$serverNode = $xml.CreateElement("Server")
|
|
$serverNode.SetAttribute("name", "WEB01")
|
|
$serverNode.SetAttribute("role", "Web")
|
|
$root.AppendChild($serverNode) | Out-Null
|
|
|
|
$xml.Save("C:\Config\servers.xml")
|
|
```
|
|
|
|
### JSON — Deep Manipulation
|
|
```powershell
|
|
# Parse JSON with depth (default depth is only 2 — a common gotcha)
|
|
$data = Get-Content "config.json" -Raw | ConvertFrom-Json -Depth 20
|
|
|
|
# Modify nested properties
|
|
$data.servers | Where-Object { $_.env -eq 'prod' } | ForEach-Object {
|
|
$_.maxConnections = 500
|
|
}
|
|
|
|
# Convert back — always set -Depth to avoid truncation
|
|
$data | ConvertTo-Json -Depth 20 | Set-Content "config.json"
|
|
|
|
# Compare two JSON configs
|
|
$a = Get-Content "config_old.json" -Raw | ConvertFrom-Json
|
|
$b = Get-Content "config_new.json" -Raw | ConvertFrom-Json
|
|
Compare-Object ($a | ConvertTo-Json -Depth 10 -Compress) ($b | ConvertTo-Json -Depth 10 -Compress)
|
|
```
|
|
|
|
---
|
|
|
|
## Performance Optimisation
|
|
|
|
### Measure and Profile
|
|
```powershell
|
|
# Basic timing
|
|
$time = Measure-Command { Get-ADUser -Filter * }
|
|
Write-Host "Took: $($time.TotalSeconds)s"
|
|
|
|
# Compare approaches
|
|
$methods = @{
|
|
'Pipeline filter' = { Get-Process | Where-Object { $_.CPU -gt 10 } }
|
|
'Direct filter' = { Get-Process | Where-Object CPU -gt 10 }
|
|
'Method filter' = { (Get-Process).Where({ $_.CPU -gt 10 }) }
|
|
}
|
|
$methods.GetEnumerator() | ForEach-Object {
|
|
$t = Measure-Command $_.Value
|
|
[PSCustomObject]@{ Method = $_.Key; Ms = [math]::Round($t.TotalMilliseconds, 2) }
|
|
} | Sort-Object Ms
|
|
```
|
|
|
|
### Key Optimisation Patterns
|
|
```powershell
|
|
# 1. Use .Where() and .ForEach() instead of pipeline for in-memory collections
|
|
$large = 1..100000
|
|
# Slow — pipeline overhead per element
|
|
$large | Where-Object { $_ % 2 -eq 0 } | ForEach-Object { $_ * 2 }
|
|
# Fast — method-based, no pipeline overhead
|
|
$large.Where({ $_ % 2 -eq 0 }).ForEach({ $_ * 2 })
|
|
|
|
# 2. StringBuilder for string concatenation in loops
|
|
$sb = [System.Text.StringBuilder]::new()
|
|
1..10000 | ForEach-Object { $sb.AppendLine("Line $_") | Out-Null }
|
|
$result = $sb.ToString()
|
|
# Never use $str += "..." in a loop — creates a new string object every iteration
|
|
|
|
# 3. Generic List instead of array for growing collections
|
|
# Slow — array re-allocation on every +=
|
|
$arr = @(); 1..10000 | ForEach-Object { $arr += $_ }
|
|
# Fast — List<T> amortised O(1) append
|
|
$list = [System.Collections.Generic.List[int]]::new()
|
|
1..10000 | ForEach-Object { $list.Add($_) }
|
|
|
|
# 4. Filter server-side, not client-side
|
|
# Slow — gets ALL users, filters locally
|
|
Get-ADUser -Filter * | Where-Object { $_.Department -eq "IT" }
|
|
# Fast — filter at the directory level
|
|
Get-ADUser -Filter { Department -eq "IT" }
|
|
Get-ADUser -LDAPFilter "(&(objectClass=user)(department=IT))"
|
|
|
|
# 5. Select-Object -First/Last stops the pipeline early
|
|
Get-ChildItem C:\Windows -Recurse | Select-Object -First 1 # Stops after finding first match
|
|
|
|
# 6. Use $null = or [void] to suppress pipeline output (faster than | Out-Null)
|
|
$null = $list.Add(42) # Fast
|
|
[void]$list.Add(42) # Also fast
|
|
$list.Add(42) | Out-Null # Slower — creates pipeline
|
|
```
|
|
|
|
---
|
|
|
|
## Security & Constrained Language Mode
|
|
|
|
### Check and Set Language Mode
|
|
```powershell
|
|
$ExecutionContext.SessionState.LanguageMode # FullLanguage | ConstrainedLanguage | RestrictedLanguage | NoLanguage
|
|
|
|
# What's blocked in ConstrainedLanguage mode:
|
|
# - Add-Type
|
|
# - [System.Type]::GetType()
|
|
# - New-Object for most .NET types
|
|
# - Direct .NET method calls on many types
|
|
# - Script block literals assigned to variables in certain ways
|
|
|
|
# Test if you're in constrained mode
|
|
if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') {
|
|
Write-Warning "Running in $($ExecutionContext.SessionState.LanguageMode) — some operations unavailable"
|
|
}
|
|
```
|
|
|
|
### AMSI (Antimalware Scan Interface) Awareness
|
|
```powershell
|
|
# AMSI scans PowerShell script blocks before execution in PS 5.1+
|
|
# Your scripts must not trigger AV signatures — use clear, well-documented code in production
|
|
# Check if AMSI is active:
|
|
[Ref].Assembly.GetType('System.Management.Automation.AmsiUtils') | Out-Null
|
|
# If no error, AMSI is present
|
|
|
|
# Corporate environments often have AMSI providers — test your automation scripts
|
|
# against corporate AV in dev/staging before prod deployment
|
|
```
|
|
|
|
### Code Signing
|
|
```powershell
|
|
# Sign a script with a code signing certificate
|
|
$cert = Get-ChildItem Cert:\CurrentUser\My -CodeSigningCert | Select-Object -First 1
|
|
Set-AuthenticodeSignature -FilePath "C:\Scripts\Deploy.ps1" -Certificate $cert
|
|
|
|
# Verify signature
|
|
Get-AuthenticodeSignature "C:\Scripts\Deploy.ps1"
|
|
|
|
# Set execution policy to only allow signed scripts
|
|
Set-ExecutionPolicy AllSigned -Scope LocalMachine
|
|
|
|
# Verify all scripts in a directory are signed
|
|
Get-ChildItem "C:\Scripts\*.ps1" | Get-AuthenticodeSignature |
|
|
Where-Object { $_.Status -ne 'Valid' } |
|
|
Select-Object Path, Status
|
|
```
|
|
|
|
---
|
|
|
|
## Working with .NET Directly
|
|
|
|
```powershell
|
|
# Reflection — inspect .NET types at runtime
|
|
[System.AppDomain]::CurrentDomain.GetAssemblies() |
|
|
Where-Object { $_.FullName -like "*Automation*" }
|
|
|
|
# Load a .NET assembly
|
|
Add-Type -Path "C:\Libs\CustomLib.dll"
|
|
Add-Type -AssemblyName "System.Net.Http"
|
|
|
|
# Create .NET objects
|
|
$httpClient = [System.Net.Http.HttpClient]::new()
|
|
$uri = [System.Uri]::new("https://api.example.com")
|
|
|
|
# Compile C# inline — extend PowerShell with custom .NET code
|
|
Add-Type -TypeDefinition @"
|
|
using System;
|
|
using System.Runtime.InteropServices;
|
|
|
|
public class NativeMethods {
|
|
[DllImport("kernel32.dll", SetLastError = true)]
|
|
public static extern bool SetConsoleTitle(string lpConsoleTitle);
|
|
|
|
[DllImport("advapi32.dll", SetLastError = true)]
|
|
public static extern bool OpenProcessToken(IntPtr ProcessHandle, uint DesiredAccess, out IntPtr TokenHandle);
|
|
}
|
|
"@ -Language CSharp
|
|
|
|
[NativeMethods]::SetConsoleTitle("My Admin Console")
|
|
|
|
# Generic collections with type safety
|
|
$serverDict = [System.Collections.Generic.Dictionary[string, PSObject]]::new()
|
|
$serverDict['WEB01'] = [PSCustomObject]@{ CPU = 42; Mem = 8 }
|
|
|
|
# Use LINQ via reflection (PS 7 makes this cleaner)
|
|
$list = [System.Collections.Generic.List[int]]@(5, 2, 8, 1, 9, 3)
|
|
[System.Linq.Enumerable]::OrderBy($list, [Func[int,int]]{ $args[0] })
|
|
```
|
|
|
|
---
|
|
|
|
## PowerShell Providers & PSDrives
|
|
|
|
```powershell
|
|
# List all providers
|
|
Get-PSProvider
|
|
|
|
# Create custom PSDrive for any provider
|
|
New-PSDrive -Name HKCU_Run -PSProvider Registry -Root HKCU:\Software\Microsoft\Windows\CurrentVersion\Run
|
|
Get-ChildItem HKCU_Run:
|
|
|
|
New-PSDrive -Name Logs -PSProvider FileSystem -Root \\LOGSERVER\Logs$ -Credential (Get-Credential)
|
|
Get-ChildItem Logs:\
|
|
|
|
# Write a custom provider (skeleton — implement System.Management.Automation.Provider)
|
|
# Useful for exposing non-filesystem data as a navigable hierarchy (config APIs, CMDB, etc.)
|
|
# Full implementation requires inheriting from NavigationCmdletProvider or ContainerCmdletProvider
|
|
```
|
|
|
|
---
|
|
|
|
## Enterprise Automation Patterns
|
|
|
|
### Idempotent Configuration Function
|
|
```powershell
|
|
# Always safe to run multiple times — checks before acting
|
|
function Set-ServerBaseline {
|
|
[CmdletBinding(SupportsShouldProcess)]
|
|
param([string]$ComputerName)
|
|
|
|
Invoke-Command -ComputerName $ComputerName -ScriptBlock {
|
|
# Pattern: Test → Report → Change (only if needed)
|
|
$changes = [System.Collections.Generic.List[string]]::new()
|
|
|
|
# TLS 1.0 disabled?
|
|
$tls10 = Get-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.0\Server" `
|
|
-Name Enabled -ErrorAction SilentlyContinue
|
|
if ($tls10.Enabled -ne 0) {
|
|
Set-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.0\Server" `
|
|
-Name Enabled -Value 0 -Type DWORD -Force
|
|
$changes.Add("Disabled TLS 1.0")
|
|
}
|
|
|
|
# SMBv1 disabled?
|
|
$smb1 = Get-SmbServerConfiguration | Select-Object -Expand EnableSMB1Protocol
|
|
if ($smb1) {
|
|
Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force
|
|
$changes.Add("Disabled SMBv1")
|
|
}
|
|
|
|
[PSCustomObject]@{
|
|
Server = $env:COMPUTERNAME
|
|
Changes = if ($changes.Count) { $changes -join '; ' } else { 'Already compliant' }
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Configuration Data Pattern (Separation of Code and Data)
|
|
```powershell
|
|
# config\servers.psd1 — data only, no logic
|
|
@{
|
|
Production = @{
|
|
WebServers = @("WEB01","WEB02","WEB03")
|
|
DBServers = @("SQL01","SQL02")
|
|
MaxThreads = 32
|
|
LogLevel = "WARN"
|
|
}
|
|
Staging = @{
|
|
WebServers = @("WEB-STG01")
|
|
DBServers = @("SQL-STG01")
|
|
MaxThreads = 8
|
|
LogLevel = "DEBUG"
|
|
}
|
|
}
|
|
|
|
# automation\deploy.ps1 — logic only, reads data
|
|
$config = Import-PowerShellDataFile "C:\Config\servers.psd1"
|
|
$env = $config[$TargetEnvironment]
|
|
|
|
Invoke-ParallelRemote -ComputerName $env.WebServers -ScriptBlock {
|
|
# ... deployment logic ...
|
|
} -ThrottleLimit $env.MaxThreads
|
|
```
|
|
|
|
### Retry with Exponential Backoff
|
|
```powershell
|
|
function Invoke-WithRetry {
|
|
param (
|
|
[scriptblock]$ScriptBlock,
|
|
[int]$MaxRetries = 5,
|
|
[int]$InitialDelayMs = 500,
|
|
[double]$Backoff = 2.0,
|
|
[type[]]$RetryOn = @([System.Exception])
|
|
)
|
|
|
|
$attempt = 0
|
|
$delay = $InitialDelayMs
|
|
|
|
while ($true) {
|
|
try {
|
|
return & $ScriptBlock
|
|
}
|
|
catch {
|
|
$attempt++
|
|
$shouldRetry = $RetryOn | Where-Object { $_.IsAssignableFrom($_.Exception.GetType()) }
|
|
|
|
if ($attempt -ge $MaxRetries -or -not $shouldRetry) {
|
|
Write-Error "Failed after $attempt attempt(s): $_"
|
|
throw
|
|
}
|
|
|
|
Write-Warning "Attempt $attempt failed. Retrying in $($delay)ms... ($_)"
|
|
Start-Sleep -Milliseconds $delay
|
|
$delay = [int]($delay * $Backoff)
|
|
}
|
|
}
|
|
}
|
|
|
|
# Usage
|
|
Invoke-WithRetry -MaxRetries 5 -ScriptBlock {
|
|
Invoke-RestMethod "https://flaky-api.corp.com/data"
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Debugging & Diagnostics
|
|
|
|
### PowerShell Debugger
|
|
```powershell
|
|
# Set a breakpoint on a line
|
|
Set-PSBreakpoint -Script "C:\Scripts\Deploy.ps1" -Line 42
|
|
|
|
# Set a breakpoint on a variable change
|
|
Set-PSBreakpoint -Variable "targetServer" -Mode ReadWrite
|
|
|
|
# Set a breakpoint on a command
|
|
Set-PSBreakpoint -Command "Invoke-RestMethod"
|
|
|
|
# Conditional breakpoint
|
|
Set-PSBreakpoint -Script "C:\Scripts\Deploy.ps1" -Line 42 -Action {
|
|
if ($server -eq "PROD01") { break }
|
|
}
|
|
|
|
# List and manage breakpoints
|
|
Get-PSBreakpoint
|
|
Disable-PSBreakpoint -Id 1
|
|
Remove-PSBreakpoint -Id 1
|
|
|
|
# Trace execution — shows every line as it runs
|
|
Set-PSDebug -Trace 2 # 2 = trace + variable assignments
|
|
Get-Service
|
|
Set-PSDebug -Off
|
|
|
|
# Trace specific script block
|
|
Trace-Command -Name ParameterBinding -Expression { Get-Service -Name "W3SVC" } -PSHost
|
|
```
|
|
|
|
### Verbose & Debug Stream Control
|
|
```powershell
|
|
# Write to different streams
|
|
Write-Verbose "Detailed progress info" # Stream 4 — shown with -Verbose
|
|
Write-Debug "Debug detail" # Stream 5 — shown with -Debug
|
|
Write-Information "Structured info" -Tags "Audit" # Stream 6
|
|
|
|
# Redirect streams to file for investigation
|
|
.\script.ps1 -Verbose 4>&1 | Tee-Object -FilePath "C:\Logs\verbose.log"
|
|
|
|
# Capture all streams
|
|
.\script.ps1 *>&1 | Out-File "C:\Logs\all-streams.log"
|
|
|
|
# Redirect error stream to success stream (lets you capture errors in a variable)
|
|
$output = .\script.ps1 2>&1
|
|
$errors = $output | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] }
|
|
```
|
|
|
|
---
|
|
|
|
## CI/CD Integration for PowerShell
|
|
|
|
### Pester — Unit Testing
|
|
```powershell
|
|
# Get-ServerHealth.Tests.ps1
|
|
BeforeAll {
|
|
# Load the module under test
|
|
Import-Module "$PSScriptRoot\..\MyModule.psd1" -Force
|
|
}
|
|
|
|
Describe "Get-ServerHealth" {
|
|
Context "When server is reachable" {
|
|
BeforeAll {
|
|
# Mock external dependencies — don't hit real infrastructure in tests
|
|
Mock Test-Connection { return $true }
|
|
Mock Get-CimInstance {
|
|
return [PSCustomObject]@{
|
|
FreePhysicalMemory = 4GB / 1KB
|
|
LastBootUpTime = (Get-Date).AddDays(-5)
|
|
}
|
|
}
|
|
}
|
|
|
|
It "Should return a result object" {
|
|
$result = Get-ServerHealth -ComputerName "FAKESERVER"
|
|
$result | Should -Not -BeNullOrEmpty
|
|
}
|
|
|
|
It "Should report server as online" {
|
|
$result = Get-ServerHealth -ComputerName "FAKESERVER"
|
|
$result.Online | Should -BeTrue
|
|
}
|
|
|
|
It "Should calculate uptime in days" {
|
|
$result = Get-ServerHealth -ComputerName "FAKESERVER"
|
|
$result.UptimeDays | Should -BeGreaterThan 0
|
|
}
|
|
}
|
|
|
|
Context "When server is unreachable" {
|
|
BeforeAll { Mock Test-Connection { return $false } }
|
|
|
|
It "Should return Online as false" {
|
|
$result = Get-ServerHealth -ComputerName "DEADSERVER"
|
|
$result.Online | Should -BeFalse
|
|
}
|
|
}
|
|
}
|
|
|
|
# Run tests with coverage report
|
|
$config = New-PesterConfiguration
|
|
$config.Run.Path = ".\Tests"
|
|
$config.CodeCoverage.Enabled = $true
|
|
$config.CodeCoverage.Path = ".\Public\*.ps1"
|
|
$config.Output.Verbosity = "Detailed"
|
|
Invoke-Pester -Configuration $config
|
|
```
|
|
|
|
### Azure DevOps / GitHub Actions Pipeline Task
|
|
```yaml
|
|
# azure-pipelines.yml snippet for PowerShell module CI
|
|
- task: PowerShell@2
|
|
displayName: 'Run PSScriptAnalyzer'
|
|
inputs:
|
|
targetType: inline
|
|
script: |
|
|
Install-Module PSScriptAnalyzer -Force -Scope CurrentUser
|
|
$results = Invoke-ScriptAnalyzer -Path ./MyModule -Recurse -Severity Error,Warning
|
|
if ($results) {
|
|
$results | Format-Table -AutoSize
|
|
throw "PSScriptAnalyzer found $($results.Count) issues"
|
|
}
|
|
|
|
- task: PowerShell@2
|
|
displayName: 'Run Pester Tests'
|
|
inputs:
|
|
targetType: inline
|
|
script: |
|
|
Install-Module Pester -MinimumVersion 5.0 -Force -Scope CurrentUser
|
|
$config = New-PesterConfiguration
|
|
$config.Run.Path = './Tests'
|
|
$config.TestResult.Enabled = $true
|
|
$config.TestResult.OutputPath = '$(Agent.TempDirectory)/pester.xml'
|
|
$result = Invoke-Pester -Configuration $config -PassThru
|
|
if ($result.FailedCount -gt 0) { throw "$($result.FailedCount) tests failed" }
|
|
|
|
- task: PublishTestResults@2
|
|
inputs:
|
|
testResultsFormat: NUnit
|
|
testResultsFiles: '$(Agent.TempDirectory)/pester.xml'
|
|
```
|
|
|
|
### PSScriptAnalyzer — Lint & Best Practice Enforcement
|
|
```powershell
|
|
Install-Module PSScriptAnalyzer -Scope CurrentUser
|
|
|
|
# Analyse a script or module
|
|
Invoke-ScriptAnalyzer -Path "C:\Scripts\Deploy.ps1"
|
|
Invoke-ScriptAnalyzer -Path "C:\Modules\MyModule" -Recurse
|
|
|
|
# Custom rules configuration — PSScriptAnalyzerSettings.psd1
|
|
@{
|
|
Severity = @('Error', 'Warning')
|
|
ExcludeRules = @('PSAvoidUsingWriteHost') # Suppress specific rules
|
|
IncludeRules = @(
|
|
'PSAvoidUsingPlainTextForPassword',
|
|
'PSUseShouldProcessForStateChangingFunctions',
|
|
'PSUseApprovedVerbs',
|
|
'PSAvoidGlobalVars'
|
|
)
|
|
}
|
|
|
|
# Apply custom settings
|
|
Invoke-ScriptAnalyzer -Path . -Recurse -Settings "C:\Config\PSScriptAnalyzerSettings.psd1"
|
|
|
|
# Auto-fix certain violations
|
|
Invoke-ScriptAnalyzer -Path ".\script.ps1" -Fix
|
|
```
|
|
|
|
---
|
|
|
|
## Quick Reference — Advanced Operators & Syntax
|
|
|
|
```powershell
|
|
# Null coalescing (PS 7+)
|
|
$value = $data.Setting ?? "default"
|
|
|
|
# Null conditional member access (PS 7+)
|
|
$len = $str?.Length # Returns null if $str is null, no error
|
|
|
|
# Ternary operator (PS 7+)
|
|
$label = $isProduction ? "PROD" : "NON-PROD"
|
|
|
|
# Pipeline chain operators (PS 7+)
|
|
Start-Service W3SVC && Write-Host "Started" || Write-Host "Failed"
|
|
|
|
# ForEach-Object -Parallel with timeout
|
|
$servers | ForEach-Object -Parallel { ... } -ThrottleLimit 10 -TimeoutSeconds 60
|
|
|
|
# Splatting — cleaner than long lines
|
|
$params = @{
|
|
ComputerName = "SERVER01"
|
|
ScriptBlock = { hostname }
|
|
Credential = $cred
|
|
ErrorAction = 'Stop'
|
|
}
|
|
Invoke-Command @params
|
|
|
|
# Multiple assignment
|
|
$a, $b, $c = 1, 2, 3
|
|
$first, $rest = 1, 2, 3, 4, 5 # $first = 1; $rest = 2,3,4,5
|
|
|
|
# Hash table merging (PS 7+)
|
|
$base = @{ A = 1; B = 2 }
|
|
$overlay = @{ B = 99; C = 3 }
|
|
$merged = $base + $overlay # { A=1; B=99; C=3 } — overlay wins
|
|
|
|
# Script block as first-class value
|
|
$validators = @{
|
|
IsPositive = { param($n) $n -gt 0 }
|
|
IsServer = { param($s) $s -match '^SERVER\d+$' }
|
|
}
|
|
& $validators['IsServer'] "SERVER01" # True
|
|
```
|
|
|
|
---
|
|
|
|
*Last updated: 2026 | PowerShell 5.1 / 7.x — notes indicate version-specific features*
|