Detect Drift with PowerShell Compare-Object: Baseline Hashes to Catch NEW, CHANGED, REMOVED
Configuration drift is inevitable: a hotfix on a server, a quiet dependency bump, a file edited directly in a container. The fastest way to regain control is to baseline the state you intend, then detect deviations on every run. In PowerShell, Compare-Object plus file SHA-256 hashes gives you a simple, robust pattern to surface NEW, CHANGED, and REMOVED items before they ever reach production.
In this post, you will stage a baseline once, hash file content so you detect real changes (not just timestamps), and emit clear results that are easy to review in code review, CI logs, or dashboards.
Why Compare-Object for Drift Detection?
Compare-Object is a built-in PowerShell cmdlet that finds differences between two collections. It marks each difference with a SideIndicator:
=>: Present in the new collection, not in the old (NEW)<=: Present in the old collection, not in the new (REMOVED)
By comparing collections of objects that include both Path and Hash, you can also detect files that exist in both states but whose content differs (CHANGED). Combine the results and you get a concise summary for fast reviews.
Step-by-Step: Baseline Once, Compare on Each Run
1) Gather state: path + SHA-256 hash
Start by walking the directory you care about and computing a hash for each file. Using the file content hash ensures you detect real changes even when modified timestamps or attributes are misleading.
$root = 'C:\app'
$baseline = '.\baseline.csv'
function Get-State {
param(
[Parameter(Mandatory)]
[string]$Path,
[string[]]$ExcludePatterns
)
$files = Get-ChildItem -Path $Path -File -Recurse -ErrorAction Stop
if ($ExcludePatterns) {
$files = $files | Where-Object {
$full = $_.FullName
-not ($ExcludePatterns | ForEach-Object { $full -like $_ } | Where-Object { $_ })
}
}
$files | ForEach-Object {
[PSCustomObject]@{
Path = $_.FullName
Hash = (Get-FileHash -Path $_.FullName -Algorithm SHA256).Hash
}
}
}
Optional: pass patterns like *.log, *\bin\*, or *\node_modules\* to exclude transient files that add noise.
2) Create the baseline once
On first run, persist the state as a CSV. This snapshot becomes your point of comparison in future runs.
if (-not (Test-Path -Path $baseline)) {
Get-State -Path $root -ExcludePatterns @('*\\bin\\*','*\\obj\\*','*\\node_modules\\*') |
Export-Csv -Path $baseline -NoTypeInformation
Write-Host 'Baseline created.'
exit 0
}
3) Compare using Compare-Object and classify results
On subsequent runs, compare the current state with the baseline. We compare on both Path and Hash so we can detect NEW and REMOVED files directly, and infer CHANGED when the same path has different hashes. Grouping by Path lets us collapse the pair of differences (<= old, => new) into a single CHANGED line.
$old = Import-Csv -Path $baseline
$new = Get-State -Path $root -ExcludePatterns @('*\\bin\\*','*\\obj\\*','*\\node_modules\\*')
# Compute diff on (Path,Hash)
$diff = Compare-Object -ReferenceObject $old -DifferenceObject $new -Property Path,Hash -PassThru
# Group by Path to classify NEW, CHANGED, REMOVED
$groups = $diff | Group-Object -Property Path
$results = foreach ($g in $groups) {
$indicators = ($g.Group | Select-Object -ExpandProperty SideIndicator | Sort-Object -Unique) -join ''
switch ($indicators) {
'=>' { [PSCustomObject]@{ Status='NEW'; Path=$g.Name } }
'<=' { [PSCustomObject]@{ Status='REMOVED'; Path=$g.Name } }
'=><=' { [PSCustomObject]@{ Status='CHANGED'; Path=$g.Name } }
}
}
# Human-friendly output
$results | Sort-Object Status, Path | ForEach-Object { "{0,-8} {1}" -f $_.Status, $_.Path }
# Non-zero exit code if drift detected (useful in CI)
if ($results) { exit 2 } else { Write-Host 'No drift detected.'; exit 0 }
Example output:
NEW C:\app\config\feature.json
CHANGED C:\app\web.config
REMOVED C:\app\secrets\dev.key
This classification is easy to scan in logs and quick to triage in reviews.
4) Optional: Update the baseline with intent
When a change is authorized (e.g., approved via pull request), you can update the baseline. Gate this behind a parameter or environment variable so it only happens on approved runs.
param(
[switch]$UpdateBaseline
)
if ($UpdateBaseline) {
$new | Export-Csv -Path $baseline -NoTypeInformation
Write-Host 'Baseline updated.'
}
Practical Tips for Accuracy and Performance
Choose the right hashing strategy
- SHA-256 is a good default: collision-resistant and widely available.
- If you only need speed (not cryptographic strength), MD5 is faster, but use with care. Prefer SHA-256 for prod policy.
- Hashing is CPU-bound. On large trees, consider parallelization with PowerShell 7:
$files | ForEach-Object -Parallel {
[PSCustomObject]@{
Path = $_.FullName
Hash = (Get-FileHash -Path $_.FullName -Algorithm SHA256).Hash
}
} -ThrottleLimit 4
Ignore noisy or ephemeral files
- Exclude build outputs:
*\bin\*,*\obj\*,*\dist\*,*\node_modules\* - Exclude logs, caches, temp files:
*.log,*.tmp,*\.cache\* - Consider a
.driftignorefile alongside your baseline and read patterns from it:
$ignoreFile = Join-Path $root '.driftignore'
$patterns = Test-Path $ignoreFile ? (Get-Content $ignoreFile | Where-Object { $_ -and -not $_.StartsWith('#') }) : @()
$new = Get-State -Path $root -ExcludePatterns $patterns
Normalize line endings only if you must
Hashing raw bytes is precise, but cross-platform line endings can cause false positives. If you baseline on Linux and verify on Windows, you may want a text-normalized hash for specific file types:
function Get-NormalizedFileHash {
param([string]$Path)
$text = Get-Content -Path $Path -Raw -ErrorAction Stop
$normalized = $text -replace '\r\n', '\n' -replace '\r', '\n'
$bytes = [System.Text.Encoding]::UTF8.GetBytes($normalized)
$stream = New-Object System.IO.MemoryStream(,$bytes)
(Get-FileHash -InputStream $stream -Algorithm SHA256).Hash
}
Use normalized hashing only for known text files; keep binary files on raw byte hashing.
Make output review-friendly
- Emit machine-readable JSON alongside human output:
$results | ConvertTo-Json -Depth 3 | Set-Content -Path '.\\drift.json'
- Sort by status and path, and use fixed-width alignment for quick scanning.
- Fail the build when drift exists unless the run is explicitly marked as an “update baseline” job.
Automate in CI/CD and Across Environments
GitHub Actions example
name: Drift Check
on: [push, pull_request]
jobs:
drift:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Run drift detection
shell: pwsh
run: |
$root = 'C:\\app'
$baseline = '.\\baseline.csv'
.\\scripts\\drift.ps1 -Root $root -Baseline $baseline
- name: Upload drift report
if: failure()
uses: actions/upload-artifact@v4
with:
name: drift
path: drift.json
For PRs, keep the baseline in the repository so reviewers see both the code change and the updated snapshot when appropriate.
Azure DevOps or Jenkins
The same pattern applies: run the script, fail on drift, and publish the drift report as a build artifact. Use environment variables (e.g., UPDATE_BASELINE=true) in a protected stage to refresh the baseline only after approval.
Containers and cloud hosts
- Use a volume mount for the baseline so it persists across ephemeral containers.
- In Kubernetes, store the baseline in a ConfigMap or a versioned object in blob storage and mount or fetch it at runtime.
- In cloud VMs, schedule the script via Task Scheduler or a cron-equivalent and push results to your SIEM or storage account.
Beyond Files: Registry, ACLs, and App Settings
You can extend the concept to other system facets. The trick is always the same: project each item to a stable identity plus a digest of the attributes you care about.
Registry values
function Get-RegistryState {
param([string]$RootKey)
Get-ChildItem -Path $RootKey -Recurse -ErrorAction SilentlyContinue |
ForEach-Object {
Get-ItemProperty -Path $_.PsPath | Select-Object -Property * -ExcludeProperty PS*, Property | ForEach-Object {
foreach ($name in $_.PSObject.Properties.Name) {
if ($name -notlike 'PS*') {
[PSCustomObject]@{
Path = "{0}::{1}" -f $_.PSPath, $name
Hash = ("{0}" -f $_.$name | ConvertTo-Json -Compress | % { $_ }) | ForEach-Object {
$bytes = [System.Text.Encoding]::UTF8.GetBytes($_)
$stream = New-Object System.IO.MemoryStream(,$bytes)
(Get-FileHash -InputStream $stream -Algorithm SHA256).Hash
}
}
}
}
}
}
}
ACLs on critical files
function Get-AclState {
param([string]$Path)
Get-ChildItem -Path $Path -File -Recurse | ForEach-Object {
$acl = Get-Acl -Path $_.FullName
$sddl = $acl.Sddl
[PSCustomObject]@{ Path = $_.FullName; Hash = (New-Object System.IO.MemoryStream(,[Text.Encoding]::UTF8.GetBytes($sddl)) | %{ (Get-FileHash -InputStream $_ -Algorithm SHA256).Hash }) }
}
}
Swap Get-State with these sources and reuse the same comparison logic to detect drift in registry entries or permissions.
Putting It All Together
With a few lines of PowerShell, you can:
- Stage a baseline of file content using SHA-256.
- Use Compare-Object to classify differences as NEW, CHANGED, or REMOVED.
- Fail builds on drift, publish a JSON report, and only update the baseline with explicit approval.
- Exclude noisy paths and normalize text when necessary to avoid false positives.
The result: fewer surprises, clearer diffs, faster reviews, and safer releases. Start by baselining your most critical directories today, then wire the drift check into your CI so every change is audited with intent.
Further reading: PowerShell Advanced Cookbook