The first post in this series covers what OrphanedSid does. This one’s about moving it out of the place it grew up.

Find-UnknownSID lived at Powershell/Active Directory/Find-UnknownSID in my TechbyJeff repo, next to everything else I’ve written. By the time I went back to it, that one folder held 307 files: 129 under Tests, 81 under Documentation, 63 under Private. One of them was the script.

I was looking at the traffic this blog gets, and Understanding Orphaned SIDs in Active Directory was the most-read post after my homelab stuff. So I figured people would be interested if I made it a bit more polished and accessible.

A shared repo is a fine place for a script. It’s an awkward place for something with a version number. My other Gallery modules publish when I push a tag, and the release workflow refuses to run unless the tag matches the manifest:

1
2
3
4
5
$tag = '${{ github.ref_name }}' -replace '^v', ''
$manifest = (Test-ModuleManifest ./OrphanedSid.psd1).Version.ToString()
if ($tag -ne $manifest) {
    throw "Tag '${{ github.ref_name }}' resolves to $tag but the manifest declares $manifest. Bump ModuleVersion and retag."
}

A tag belongs to a whole repository. In a repo with dozens of unrelated projects, v1.0.0 doesn’t mean anything.

Reading what the script claimed to do

Before moving anything, I audited the script’s documentation against its code. Seventeen of the 24 documents described a tool that didn’t exist: parallel processing with a thread count, objects on the pipeline, -MaxThreads, -Credential, -PassThru, exit codes 0 through 10, JSON backups, role-based access checks. The code had none of it.

The problem with the documentation mainly came from me cobbling together other scripts of mine with other functions, or failing to prune the docs after I decided to remove the code behind them.

The audit also turned up parameters that were accepted and then ignored. -Force was declared and never read. -PreserveTempFiles did nothing, -LogLevel never filtered the file log, and BatchSize was validated and dropped on the floor. A script with -Remove and a -Force nobody checks isn’t a great combination, so that one got a real gate:

1
2
if (($Remove -or $Restore) -and -not $Force -and -not $WhatIfPreference) {
    $canPrompt = [Environment]::UserInteractive -and $null -ne $Host.UI.RawUI -and $Host.Name -ne 'ServerRemoteHost'

Invoke-GarbageCollection.ps1 defined fallback copies of Write-StructuredLog and Get-CurrentMemoryUsage at load time, and those replaced the real functions. Every log line after startup went to Write-Verbose and nowhere else, in a project whose headline feature was its logging.

All of that went in as one commit, while the code was still a script in the old repo.

Moving it with its history

git subtree split takes one folder and synthesizes a branch where that folder is the root, keeping only the commits that touched it. That gets you a new repo with the old log:

1
git subtree split --prefix='Powershell/Active Directory/Find-UnknownSID' --branch orphanedsid-split

It came across as 68 commits going back to July 2025. Every hash changes, because each commit’s tree is different now. The last pre-move commit is 149601f in the old repo and 59279d4 in the new one, with the same message and date.

The old folder didn’t get deleted outright, because two blog posts link to it. It became a pointer README that maps the old usage to the new commands:

Script usageModule command
.\Find-UnknownSID.ps1 -SearchBase <dn>Find-OrphanedSid -SearchBase <dn>
.\Find-UnknownSID.ps1 -SearchBase <dn> -Remove -ForceRemove-OrphanedSid -SearchBase <dn> -Force
.\Find-UnknownSID.ps1 -Restore -SearchBase <dn> -BackupPath <folder>Restore-OrphanedSidBackup -TargetObjectDN <dn> -BackupPath <folder>

A find-unknownsid-final tag marks where the in-tree script stopped, and I repointed the posts’ main links at the new repository.

Splitting the entry point

The old entry point was 1,075 lines. Its param block used three parameter sets, Discovery, Removal and Restore, picked by whether you passed -Remove or -Restore. Some of the validation had to work out which mode it was in:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
[ValidateScript({
    # For Restore operations, the directory must exist
    if ($_ -and $PSCmdlet.ParameterSetName -eq 'Restore' -and -not (Test-Path $_ -PathType Container)) {
        throw "Backup directory not found for restore operation: $_"
    }
    # For Removal operations, we can create the directory if it doesn't exist
    if ($_ -and $PSCmdlet.ParameterSetName -eq 'Removal') {
        $parentDir = Split-Path -Parent $_
        if ($parentDir -and -not (Test-Path $parentDir -PathType Container)) {
            throw "Parent directory does not exist for backup path: $parentDir"
        }
    }
    $true
})]
[string]$BackupPath,

Split into commands, each one states only its own rule. Restore-OrphanedSidBackup has a mandatory -BackupPath that must exist, and Remove-OrphanedSid checks the parent folder. Neither needs to know the other exists. ConfirmImpact = 'High' also replaced that hand-written prompt logic I’d added hours earlier, since SupportsShouldProcess does the job for free on a real command.

A big stretch of the script, roughly lines 600 through 800, was loading. It dot-sourced its pieces in a careful order from a list of file names. The module’s root is 84 lines and its order fits in a comment:

1
2
3
# Private functions, one per file, grouped in folders by area. Logging\ goes first because every
# other function logs through Write-StructuredLog, and its own files depend on
# Initialize-LoggingConfiguration having defined the shared state.

Classes load first, then Private by area, then Public. New private folders still load even if nobody adds them to the list.

State that outlives a run

A script gets a fresh scope every time you run it. A module doesn’t. Its $script: variables stick around between commands for as long as it’s imported, so anything the script could assume was empty might now hold the previous run’s results.

Start-OrphanedSidSession runs in the begin block of all three commands and resets that explicitly:

1
2
3
4
# Reset per-run state so one command's results never leak into the next in the same session
$script:StreamingResults = $null
$script:RemovalResults = @()
$script:CurrentDomainSID = $null

Its partner, Stop-OrphanedSidSession, sits in each command’s finally and clears the rest. Since a finally that throws would hide the original error, it downgrades its own failures to warnings. Each command is now a thin wrapper: validate, start a session, call the engine, write objects, stop the session. Find-OrphanedSid is 123 lines, and about half of that’s its param block.

What broke between script and module

The first Import-Module didn’t work. Seventeen private files ended with a line like this, outside any function:

1
Write-StructuredLog "ACL retrieval module loaded successfully" -Level Debug -CorrelationId $([System.Guid]::NewGuid().ToString())

In the script that was harmless, because the logging system was dot-sourced before anything else. A module loads files in folder order, and inside Logging\, Write-RemovalSecurityLog.ps1 sorts ahead of Write-StructuredLog.ps1, so that file called a function that wasn’t defined yet. Nobody needs a debug line announcing that a file loaded, so they all went.

The class-hash layer

The script had a “secure class loading” layer. Before dot-sourcing its own class files it would validate them against recorded SHA256 hashes. It had five functions in Private/ClassManagement, a hash generator in Tools, and pages of documentation. Before the audit, it called its integrity check like this:

1
2
$integrityResult = Test-ClassIntegrity -ClassPath $pathResult.FullPath -CorrelationId $CorrelationId
if (-not $integrityResult.IsValid) {

The parameter on Test-ClassIntegrity is -Class, and the property it returns is IntegrityPassed. I couldn’t remember what that call did, so I put the pre-audit copy back on my lab DC and ran Import-SecureClasses -ValidateIntegrity the way the loader would have:

1
Failed to validate class MemoryManager : A parameter cannot be found that matches parameter name 'ClassPath'.

Every class failed the same way. A real run never got that far, because the script never passed -ValidateIntegrity, so the check was skipped every time. Only the unit tests turned it on, against a stub of Test-ClassIntegrity that took -ClassPath.

The layer’s other problem was where it looked: $ClassesPath defaulted to ".\Classes", resolved from your current directory, so the script only worked when you ran it from its own folder. Run from C:\, the pre-audit copy stopped during startup:

1
Path resolution failed: Base directory resolution failed: Classes directory not found or not accessible: .\Classes

I fixed both in the pre-move commit, then deleted the whole layer a few hours later. It vetted the script’s own dot-sourced files, and the hashes were recorded but never enforced. A module manifest and a loader that reads from $PSScriptRoot make it redundant. Five test files went with it.

Two functions, one name

The new public command is Remove-OrphanedSid. The engine already had a private helper called Remove-OrphanedSID, and it was called like this:

1
2
3
4
5
6
7
$removalParams = @{
    ObjectDN = $orphanedResult.ObjectDN
    OrphanedSIDs = @($orphanedResult.OrphanedSID)
    CorrelationId = $CorrelationId
}
# ...
$removalResult = Remove-OrphanedSID @removalParams

Read those two names again. To me they’re different, and to PowerShell they’re the same, because command names ignore case. Public\ loads last, so the public command replaced the private one, and the engine would’ve ended up calling its own front door with -ObjectDN, a parameter that command doesn’t have.

The private one is Remove-OrphanedSidEntry now. Two approved-verb renames went in at the same time: Validate-BackupSignature became Test-BackupSignature, and Normalize-Whitespace became ConvertTo-NormalizedWhitespace, so the code passes PSScriptAnalyzer’s Error and Warning rules.

This was one of the first projects where I’d tried to stick to PowerShell’s approved verbs, and I clearly missed a few.

PowerShell’s case-insensitive names bit this project there for the first time, and I’d like to tell you it was the last.