The Little Engine That Could, Mk. II

A few months back I shipped a Registry Configuration Engine for Intune – JSON configs, three scopes, transaction-logged rollback, the works. The original post ended with a “What’s Next” section listing a few things I was thinking about adding. Three of those have now landed in v1.1.0, along with a pile of correctness fixes and an architectural change that I should probably have made from the start.

This is the boring sequel. No new features that change what the engine does, mostly just things that should have worked the way you’d expect, and now do.

The bug that was hiding in plain sight

The original engine enumerated user profiles by walking HKEY_USERS and picking up everything matching S-1-5-21-* or S-1-12-1-*. That works fine if every user you care about is currently logged on. The problem: a user’s hive only sits in HKEY_USERS while they’re signed in, plus a short window after sign-out before Windows unmounts it.

The result is that any User-scope remediation silently skipped users who hadn’t signed in since boot. You’d write your detection script, deploy it through Intune, and half the fleet would report compliant for the wrong reason – the engine never even checked them.

The fix is to enumerate HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList instead. Each subkey there represents a user profile that exists on the machine, mounted or not, and the ProfileImagePath value points to where the user’s NTUSER.DAT lives on disk. For SIDs already present in HKEY_USERS, the engine reuses the mounted hive. For SIDs that aren’t, it loads the NTUSER.DAT into a temporary key (HKU\RegEngineTemp_<PID>_<random>), processes the values, and unloads cleanly.

Tested on a VM with a signed-out local user account. After remediation, the values were sitting in the registry under that user’s SID:

The dismount has to flush properly or you risk leaving a locked hive behind, so the engine forces a GC pass before calling reg.exe unload. Orphan temp hives from crashed runs get cleaned up at engine startup, looking for RegEngineTemp_* keys older than a few minutes whose owning PID is no longer alive.

DeleteKey rollback now actually works

The original Invoke-RollbackMode had a known gap: for DeleteKey actions, the transaction log just stored a placeholder saying “key structure not backed up for rollback.” Honest, but not useful. If you ran a remediation that deleted a key with subvalues, rollback would skip it.

v1.1 fixes this by using reg.exe export to dump the entire subtree to a .reg file before deletion. The transaction entry records the path to the .reg file, and rollback runs reg.exe import against it. Tested end-to-end against a config that creates a key with values, then a second config that DeleteKeys it. After rollback, the key and all its values are restored.

There’s still one case where this can’t work: a DeleteKey against a DefaultUser scope (the template hive at C:\Users\Default\NTUSER.DAT). The hive is mounted to a temporary location during the operation, so the .reg file references that temp path which won’t exist at rollback time. The engine emits a clear warning and points you at the .reg file for manual recovery.

URL configs got SHA-256 verification

The original engine would happily download a config from any URL and apply it. That’s a substantial trust assumption – anyone with write access to the storage location effectively has SYSTEM on every device pulling from there.

v1.1 hardens this:

  • HTTPS-only. http:// URLs are rejected.
  • Timeout of 30 seconds. No more hanging on a dead endpoint.
  • No automatic redirects. The URL you pin is the URL you get.
  • New -ConfigSha256 parameter. Pass the expected hash, the engine verifies it before parsing the JSON, before any registry access happens.

Hash mismatch produces a clear error showing both expected and actual values, which is what you actually want when debugging:

ERROR - SHA-256 mismatch for downloaded configuration.
Expected: A33017EA09E34476C3583576BFFCBAB7B6CD1A489724FBA980051F40E57FFCF4
Actual:   C33017EA09E34476C3583576BFFCBAB7B6CD1A489724FBA980051F40E57FFCF4

Compute the hash from PowerShell with Get-FileHash -Algorithm SHA256 .\config.json.

Single source of truth for the engine

This is the architectural change I wish I’d made from the start.

Originally, New-IntunePackage.ps1 re-implemented the engine logic inside a here-string, then injected your config into that template. Two implementations, two places to fix bugs, predictable drift. Every fix listed above would have needed to land in both the engine and the packaged template. Test coverage on the template was zero.

v1.1 inverts the relationship. The engine itself contains an injection region:

#region INJECTION_POINT
# Replaced by New-IntunePackage.ps1 during generation.
$script:__EmbeddedConfig = $null
$script:__ForcedMode     = $null
$script:__ForcedEventLog = $false
#endregion

Standalone runs leave these as null and the engine behaves exactly as before. The package generator reads the engine source, regex-replaces the injection region with the populated values (config as a here-string, mode forced to Detect or Remediate, optional event log flag), and writes out two self-contained scripts.

The generator dropped from a parallel implementation to about 90 lines of mostly argument parsing. Engine fixes propagate to all future packages automatically. There’s no second copy of the logic to forget about.

Smaller things worth mentioning

  • Three log sinks, all tagged. The engine now writes to stdout (for Intune to pick up), a local file at C:\ProgramData\RegistryConfigEngine\Logs\RegistryConfigEngine.log (always on), and the Windows Event Log (opt-in via -CreateEventLog). Every line includes a config identifier so you can tell which deployment produced which entries. Rollback runs tag with the original config name, not the transaction filename, because the identifier is embedded in the transaction log.
  • Opt-in case-sensitive comparisons. Add "caseSensitive": true to a value, and string comparisons (Equals, NotEquals, Contains, StartsWith, EndsWith) use ordinal matching instead of the default case-insensitive compare. Default behavior unchanged for backward compatibility.
  • Tests and CI. 41 Pester tests now cover the value conversion, comparison, and variable expansion functions. GitHub Actions runs the analyzer + tests + a smoke test on every push and PR. Two latent bugs in array handling surfaced during test development and got fixed before they could hit production.
  • A few smaller correctness fixes. Binary value parser branches were overlapping (a hex string without commas could fall into the wrong branch). Transaction log filenames could collide if two runs landed in the same second. Variable expansion only walked top-level strings, not multistring array elements. None of these would necessarily bite you, but they’re the kind of thing you want to find before someone else does.

Get it

Same place as before:

github.com/haakonwibe/registry-configuration-engine-v1

v1.1 was a heavy AI-assisted session, with Claude Code doing the heavy lifting and Claude Opus reviewing the architecture decisions

If you’re already running v1.0, the upgrade is drop-in. The JSON schema gained one optional property (caseSensitive), nothing existing breaks. Old transaction logs from v1.0 still roll back cleanly via the filename-fallback path.

The remaining items from the original “What’s Next” list (prerequisite checks, enhanced toast notifications) are still on the table. If you’ve actually deployed this and have feedback on what’s missing, the issue tracker is the right place.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.