monitor: add per-browser blocklist/allowlist consistency check#1
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new EnforceBlockAllowlistConsistency function that ensures no extension ID present in a browser's blocklist also appears in that same browser's allowlist. The check runs per-browser (Chrome, Edge, Firefox independently) both at startup and on live forcelist detection.
Changes:
- New
EnforceBlockAllowlistConsistencyfunction inpkg/monitor/monitor.gothat iterates allowlist subkeys, reads the corresponding blocklist live from the registry, and removes conflicting entries. - Calls the consistency check from
PrintDiff(after forcelist processing) and fromrunApp(afterProcessExistingPolicies, before broader allowlist cleanup). - Updates
newState.Subkeysto track newly created blocklist keys in the live state.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
pkg/monitor/monitor.go |
Adds EnforceBlockAllowlistConsistency function and calls it from PrintDiff after forcelist processing; updates state to include new blocklist key. |
cmd/WindowsBrowserGuard/main.go |
Calls EnforceBlockAllowlistConsistency at startup between ProcessExistingPolicies and CleanupAllowlists. |
There was a problem hiding this comment.
Pull request overview
Adds a startup + live-change post-processing step in the monitor to keep each browser’s allowlist consistent with its blocklist (removing allowlist entries that are also blocked, and deleting now-empty allowlist keys).
Changes:
- Call
monitor.EnforceBlockAllowlistConsistencyafterProcessExistingPoliciesat startup. - Call
monitor.EnforceBlockAllowlistConsistencyafter live Chrome forcelist handling inPrintDiff. - Add
EnforceBlockAllowlistConsistencyimplementation that checks allowlist keys against same-browser blocklists and cleans up conflicts.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
pkg/monitor/monitor.go |
Adds the consistency enforcement function and invokes it after live forcelist processing. |
cmd/WindowsBrowserGuard/main.go |
Runs the new consistency enforcement step during startup after existing-policy processing. |
pkg/monitor/monitor.go
Outdated
| // Post-process: for this browser only, verify no blocked | ||
| // extension remains in the same-browser allowlist | ||
| // (Chrome vs Chrome, Edge vs Edge - never cross-browser). |
There was a problem hiding this comment.
The comment says this post-process step is “for this browser only”, but EnforceBlockAllowlistConsistency iterates over all allowlist keys in state (across all browsers). Either update the comment to reflect actual behavior, or change EnforceBlockAllowlistConsistency to accept a browser/path scope and only enforce for the relevant browser.
| // Post-process: for this browser only, verify no blocked | |
| // extension remains in the same-browser allowlist | |
| // (Chrome vs Chrome, Edge vs Edge - never cross-browser). | |
| // Post-process: verify that no blocked extension remains present | |
| // in any browser's allowlist as represented in newState (cross-browser | |
| // consistency across all known policy keys). |
pkg/monitor/monitor.go
Outdated
| // Read the blocklist live so entries added just before this call are visible. | ||
| blocklistValues, err := registry.ReadKeyValues(keyPath, blocklistPath) | ||
| if err != nil || len(blocklistValues) == 0 { | ||
| telemetry.Printf(ctx, " ℹ️ Blocklist is empty or absent – nothing to enforce\n") | ||
| continue | ||
| } |
There was a problem hiding this comment.
registry.ReadKeyValues errors are treated the same as “blocklist is empty or absent”. This can silently skip enforcement and emit a misleading message when the read fails (e.g., access denied/corrupt key). Handle err separately: log/record the error and continue, and only treat it as empty when err == nil && len(blocklistValues) == 0.
pkg/monitor/monitor.go
Outdated
|
|
||
| // Read allowlist values live for accurate comparison. | ||
| allowlistValues, err := registry.ReadKeyValues(keyPath, subkeyPath) | ||
| if err != nil || len(allowlistValues) == 0 { |
There was a problem hiding this comment.
Same issue for allowlist reads: err != nil is currently reported as “Allowlist is empty”, which can hide real failures and skip cleanup. Split the cases so errors are surfaced (and enforcement can be skipped intentionally) instead of being treated as an empty allowlist.
| if err != nil || len(allowlistValues) == 0 { | |
| if err != nil { | |
| telemetry.Printf(ctx, " ❌ Failed to read %s allowlist at %s\\%s: %v\n", browser, keyPath, subkeyPath, err) | |
| // Skip enforcement for this browser on read failure, but surface the error. | |
| continue | |
| } | |
| if len(allowlistValues) == 0 { |
| // Delete the key if it is now empty to leave no orphan keys behind. | ||
| remaining, _ := registry.ReadKeyValues(keyPath, subkeyPath) | ||
| if len(remaining) == 0 { | ||
| telemetry.Printf(ctx, " 🗑️ Allowlist empty after conflict removal, deleting: %s\n", subkeyPath) | ||
| if err := registry.DeleteRegistryKeyRecursive(keyPath, subkeyPath, !canWrite); err != nil { | ||
| telemetry.Printf(ctx, " ❌ Failed to delete empty allowlist key: %v\n", err) |
There was a problem hiding this comment.
remaining, _ := registry.ReadKeyValues(...) ignores errors; if the read fails, remaining will be nil and len(remaining) == 0, which can trigger deletion of the allowlist key even though it might still contain values (or the failure was transient). Handle the error and only delete the key when err == nil and the key is confirmed empty.
pkg/monitor/monitor.go
Outdated
| if !admin.IsAdmin() { | ||
| return |
There was a problem hiding this comment.
This function returns immediately when not running as Administrator, which means dry-run mode without admin won’t perform the consistency check or report planned removals. ProcessExistingPolicies explicitly supports non-admin dry-run by doing read-only + “[DRY-RUN]” operations; consider using the same pattern here (e.g., only require admin when canWrite is true, but still run the check when !canWrite).
| if !admin.IsAdmin() { | |
| return | |
| if canWrite && !admin.IsAdmin() { | |
| telemetry.Println(ctx, "[DRY-RUN] Not running as Administrator; enforcing blocklist/allowlist consistency in read-only mode") | |
| canWrite = false |
| telemetry.Println(ctx, "========================================") | ||
| fmt.Println() | ||
| } |
There was a problem hiding this comment.
This uses fmt.Println() for output. The project convention is to log via telemetry.Printf/Println so output is consistently routed to stdout/log file/OTel; prefer telemetry.Println(ctx, "") (or similar) instead of fmt.Println().
pkg/monitor/monitor.go
Outdated
| if err := registry.RemoveFromAllowlist(keyPath, subkeyPath, extID, !canWrite); err != nil { | ||
| telemetry.Printf(ctx, " ❌ Failed to remove %s from allowlist: %v\n", extID, err) | ||
| } else { | ||
| telemetry.Printf(ctx, " ✓ Removed %s from %s allowlist\n", extID, browser) |
There was a problem hiding this comment.
In dry-run mode (canWrite == false), registry.RemoveFromAllowlist(..., dryRun=true) returns nil without changing the registry, but the success log still says the ID was "Removed". This makes dry-run output misleading. Consider branching the success message on canWrite (e.g., "Would remove" vs "Removed") so logs accurately reflect whether a write occurred.
| telemetry.Printf(ctx, " ✓ Removed %s from %s allowlist\n", extID, browser) | |
| if canWrite { | |
| telemetry.Printf(ctx, " ✓ Removed %s from %s allowlist\n", extID, browser) | |
| } else { | |
| telemetry.Printf(ctx, " ✓ Would remove %s from %s allowlist (dry run)\n", extID, browser) | |
| } |
pkg/monitor/monitor.go
Outdated
| // Keep newState in sync with the registry: AddToBlocklist | ||
| // may have created this key after the state snapshot was taken. | ||
| // (The consistency check reads the blocklist live from the registry.) | ||
| newState.Subkeys[blocklistKeyPath] = true |
There was a problem hiding this comment.
newState.Subkeys[blocklistKeyPath] = true is applied unconditionally, even if AddToBlocklist failed or never created the key. Because previousState is set to newState after PrintDiff, this can desync the in-memory snapshot from the real registry and potentially hide future diffs (e.g., a later successful creation would no longer appear as a subkey add). Consider only updating newState after confirming the key exists (or after at least one successful AddToBlocklist), or simply recapture state after writes instead of mutating the snapshot based on an assumption.
pkg/monitor/monitor.go
Outdated
| if !canWrite && !admin.IsAdmin() { | ||
| telemetry.Println(ctx, "\n========================================") | ||
| telemetry.Println(ctx, "Enforcing blocklist/allowlist consistency...") | ||
| telemetry.Println(ctx, "(DRY-RUN MODE - showing planned operations)") | ||
| telemetry.Println(ctx, "========================================") | ||
| } else if !admin.IsAdmin() && canWrite { | ||
| telemetry.Println(ctx, "\n⚠️ Not running as Administrator - skipping blocklist/allowlist consistency check") | ||
| return | ||
| } else { | ||
| telemetry.Println(ctx, "\n========================================") | ||
| telemetry.Println(ctx, "Enforcing blocklist/allowlist consistency...") | ||
| telemetry.Println(ctx, "========================================") |
There was a problem hiding this comment.
The dry-run / privilege banner logic is inconsistent with how the function actually performs writes. When canWrite is false but the process is admin (e.g., --dry-run or write-disabled), the code falls into the non-dry-run banner, yet all registry mutations are still invoked with dryRun = !canWrite. Consider keying the banner strictly off canWrite (dry-run vs real) and using the admin check only to decide whether to skip when writes are required.
pkg/monitor/monitor.go
Outdated
| } else if canWrite { | ||
| if _, readErr := registry.ReadKeyValues(keyPath, blocklistKeyPath); readErr == nil { | ||
| blocklistConfirmed = true | ||
| } else { | ||
| telemetry.Printf(ctx, " ⚠️ Failed to confirm blocklist key after write: %v\n", readErr) | ||
| } |
There was a problem hiding this comment.
The post-write confirmation re-reads the entire blocklist key for every successfully added extension ID, which adds extra registry I/O on the hot path. Since AddToBlocklist already creates/opens the key and returns nil on success, consider marking the blocklist as present in newState immediately on successful AddToBlocklist (or only once per forcelistKeyPath) instead of calling ReadKeyValues just to confirm existence.
pkg/monitor/monitor.go
Outdated
| blocklistValues, err := registry.ReadKeyValues(keyPath, blocklistPath) | ||
| if err != nil { | ||
| telemetry.Printf(ctx, " ❌ Failed to read %s blocklist at HKLM\\%s\\%s: %v\n", browser, keyPath, blocklistPath, err) | ||
| telemetry.RecordError(ctx, err) | ||
| continue |
There was a problem hiding this comment.
ReadKeyValues returns an error when the blocklist key doesn't exist (common case). Treating that as a ❌ and recording it as an error will spam logs/telemetry and may obscure real failures. Consider handling missing blocklist keys as "empty/absent" (no enforcement needed) and only logging/recording errors for unexpected cases (e.g., access denied). This may require updating registry.ReadKeyValues to wrap the underlying RegOpenKeyEx error with %w so windows.ERROR_FILE_NOT_FOUND can be detected via errors.Is.
cmd/WindowsBrowserGuard/main.go
Outdated
| // After processing existing forcelist policies the blocklist may have grown. | ||
| // Verify per-browser consistency before the broader allowlist wipe so that | ||
| // any remaining allowlist entries for blocked extensions are removed first. | ||
| monitor.EnforceBlockAllowlistConsistency(ctx, keyPath, previousState, canWrite) |
There was a problem hiding this comment.
At startup this consistency check runs immediately before CleanupAllowlists, which (when running as admin) deletes all allowlist keys anyway. This adds extra registry reads/writes and log output without changing the final registry outcome. Consider gating this call to scenarios where CleanupAllowlists is skipped (e.g., non-admin/dry-run) or removing it from the startup path if the intent is only to clean up during live forcelist handling.
| monitor.EnforceBlockAllowlistConsistency(ctx, keyPath, previousState, canWrite) | |
| // At startup this is only useful when we are not going to perform a full | |
| // allowlist cleanup (e.g., dry-run or lack of write privileges). | |
| if dryRun || !canWrite { | |
| monitor.EnforceBlockAllowlistConsistency(ctx, keyPath, previousState, canWrite) | |
| } |
cmd/WindowsBrowserGuard/main.go
Outdated
| monitor.ProcessExistingPolicies(ctx, keyPath, previousState, canWrite, extensionIndex) | ||
| // At startup this extra consistency pass is only useful when a full | ||
| // allowlist cleanup will not immediately follow, such as dry-run mode. | ||
| if !canWrite { | ||
| monitor.EnforceBlockAllowlistConsistency(ctx, keyPath, previousState, canWrite) | ||
| } |
There was a problem hiding this comment.
PR description says the consistency check runs at startup after ProcessExistingPolicies, but the current startup wiring only calls EnforceBlockAllowlistConsistency when !canWrite (dry-run). Either update the description to match the behavior, or run this pass in write mode as well (or remove/adjust the later CleanupAllowlists step if the intent is to preserve non-conflicting allowlist entries).
pkg/monitor/monitor.go
Outdated
| conflicts := 0 | ||
| initialAllowlistCount := len(allowlistValues) | ||
| for _, valueData := range allowlistValues { | ||
| extID := detection.ExtractExtensionIDFromValue(valueData) | ||
| if extID == "" || !blockedIDs[extID] { | ||
| continue | ||
| } | ||
| conflicts++ | ||
| totalConflicts++ | ||
| telemetry.Printf(ctx, " ⚠️ Conflict: %s is blocked but present in %s allowlist\n", extID, browser) | ||
| if err := registry.RemoveFromAllowlist(keyPath, subkeyPath, extID, !canWrite); err != nil { | ||
| telemetry.Printf(ctx, " ❌ Failed to remove %s from allowlist: %v\n", extID, err) |
There was a problem hiding this comment.
EnforceBlockAllowlistConsistency rereads and reopens the allowlist for every conflicting entry via registry.RemoveFromAllowlist, which itself calls ReadKeyValues again. This makes the enforcement pass O(n²) per allowlist and can get expensive if policies contain many entries. Consider collecting the matching value names once from allowlistValues and deleting them in a single key open (or adding a registry helper that removes multiple entries given the already-read values).
pkg/monitor/monitor.go
Outdated
| delete(state.Subkeys, subkeyPath) | ||
| for valName := range state.Values { | ||
| if strings.HasPrefix(valName, subkeyPath+"\\") { | ||
| delete(state.Values, valName) | ||
| } | ||
| } |
There was a problem hiding this comment.
After deleting an allowlist key recursively, the in-memory state cleanup only removes state.Subkeys[subkeyPath] and values with that prefix, but it does not remove any potential descendant subkeys under that allowlist path. Since DeleteRegistryKeyRecursive removes the full subtree, this can leave state.Subkeys inconsistent. Use registry.RemoveSubtreeFromState(state, subkeyPath) (and delete the root key) to ensure all descendants are removed from the captured state.
| delete(state.Subkeys, subkeyPath) | |
| for valName := range state.Values { | |
| if strings.HasPrefix(valName, subkeyPath+"\\") { | |
| delete(state.Values, valName) | |
| } | |
| } | |
| registry.RemoveSubtreeFromState(state, subkeyPath) | |
| delete(state.Subkeys, subkeyPath) |
pkg/monitor/monitor.go
Outdated
| // EnforceBlockAllowlistConsistency ensures that no extension ID present in a | ||
| // browser's blocklist also appears in that same browser's allowlist. Each | ||
| // browser's lists are compared independently because extension IDs are not | ||
| // shared across Chrome, Edge, and Firefox. |
There was a problem hiding this comment.
The docstring/PR description say Chrome, Edge, and Firefox allowlist/blocklist consistency is enforced, but this function only iterates over keys containing "ExtensionInstallAllowlist" (Chrome/Edge). If Firefox consistency is in scope, add equivalent logic for Firefox policies; otherwise, update the comment/PR description to avoid implying Firefox coverage.
| conflicts++ | ||
| totalConflicts++ | ||
| conflictingValueNames = append(conflictingValueNames, valueName) |
There was a problem hiding this comment.
totalConflicts is incremented when conflicts are detected, but the final "Resolved %d" message uses that same count even if removals fail or values are already gone. Consider tracking a separate "resolved" count based on successful deletions (or decrementing on failure) so the summary can't over-report remediations.
| telemetry.Printf(ctx, " ❌ Failed to remove conflicting allowlist entries: %v\n", err) | ||
| continue | ||
| } | ||
|
|
There was a problem hiding this comment.
This function mutates the registry (deletes allowlist values / possibly the allowlist key) but does not update the in-memory state.Values to reflect deleted value names. Because the watcher re-arms notifications only after PrintDiff returns, these self-induced registry writes may not generate a subsequent capture, so previousState can become stale. After a successful non-dry-run deletion, remove the corresponding entries from state.Values (e.g., subkeyPath + "\\" + valueName) for each deleted value name; key deletion is already handled separately.
| // Keep the in-memory state in sync with the registry after successful deletions. | |
| // We only mutate state when we actually write to the registry (non-dry-run). | |
| if canWrite { | |
| for _, valueName := range deletedValueNames { | |
| fullValuePath := subkeyPath | |
| if fullValuePath != "" { | |
| fullValuePath += `\` + valueName | |
| } else { | |
| fullValuePath = valueName | |
| } | |
| delete(state.Values, fullValuePath) | |
| } | |
| } |
|
|
||
| // Read allowlist values live for accurate comparison. | ||
| allowlistValues, err := registry.ReadKeyValues(keyPath, subkeyPath) | ||
| if err != nil { |
There was a problem hiding this comment.
When reading the allowlist key live, ReadKeyValues can return windows.ERROR_FILE_NOT_FOUND if the key was deleted between the captured state and this enforcement pass (or by a concurrent policy update). This currently logs/records an error, but should be treated as a no-op (empty/absent allowlist) similar to the blocklist handling above (use errors.Is(err, windows.ERROR_FILE_NOT_FOUND) and continue).
| if err != nil { | |
| if err != nil { | |
| if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) { | |
| telemetry.Printf(ctx, " ✓ Allowlist is empty or absent - no conflicts possible\n") | |
| continue | |
| } |
| blocklistValues, err := registry.ReadKeyValues(keyPath, blocklistPath) | ||
| if err != nil { | ||
| if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) { | ||
| telemetry.Printf(ctx, " ℹ️ Blocklist is empty or absent - nothing to enforce\n") | ||
| continue |
There was a problem hiding this comment.
In dry-run mode this pass reads the blocklist live from the registry, but earlier steps (e.g., AddToBlocklist in dry-run) don’t actually write those entries. That means the consistency check can miss conflicts that would exist after the planned blocklist updates, despite printing “showing planned operations”. Consider simulating pending blocklist additions in-memory for dry-run (or passing the intended blocked IDs into this function) so the planned allowlist removals are accurately reported.
| // EnforceBlockAllowlistConsistency ensures that no extension ID present in a | ||
| // Chromium browser's blocklist also appears in that same browser's allowlist. | ||
| // This pass only applies to policies that use ExtensionInstallAllowlist and | ||
| // ExtensionInstallBlocklist (for example Chrome and Edge). Firefox extension | ||
| // policies are handled separately and are not part of this allowlist cleanup. |
There was a problem hiding this comment.
The PR description says Firefox lists are checked independently, but this function explicitly excludes Firefox (and the code only handles Chromium allowlist/blocklist keys). Either update the PR description to match the implementation, or extend the consistency pass to cover Firefox’s equivalent allow/block policy keys (if applicable).
| keyPtr, err := syscall.UTF16PtrFromString(fullPath) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("error converting key path: %v", err) | ||
| } | ||
|
|
||
| var hKey windows.Handle | ||
| err = windows.RegOpenKeyEx(windows.HKEY_LOCAL_MACHINE, keyPtr, 0, windows.KEY_READ|windows.KEY_WRITE, &hKey) | ||
| if err != nil { | ||
| if err == windows.ERROR_FILE_NOT_FOUND { | ||
| return nil, nil | ||
| } | ||
| return nil, fmt.Errorf("error opening allowlist key: %v", err) | ||
| } |
There was a problem hiding this comment.
New errors created here use %v formatting, which drops error wrapping and makes it harder for callers to inspect root causes with errors.Is/As. Prefer %w when returning an underlying error (e.g., from UTF16PtrFromString / RegOpenKeyEx) so the original error is preserved.
pkg/monitor/monitor.go
Outdated
| for plannedID := range plannedBlockedIDs[blocklistPath] { | ||
| blockedIDs[plannedID] = true |
There was a problem hiding this comment.
plannedBlockedIDs are merged into blockedIDs unconditionally, but the docstring says planned IDs are only for dry-run. In non-dry-run mode this can treat an ID as blocked even if the blocklist write failed, causing allowlist entries to be removed based on planned (not actual) registry state. Consider only merging plannedBlockedIDs when !canWrite (dry-run), or track per-ID write success before adding it to the planned set used here.
| for plannedID := range plannedBlockedIDs[blocklistPath] { | |
| blockedIDs[plannedID] = true | |
| // In dry-run mode, also include IDs that are planned to be blocked but not yet written. | |
| if !canWrite { | |
| for plannedID := range plannedBlockedIDs[blocklistPath] { | |
| blockedIDs[plannedID] = true | |
| } |
pkg/registry/registry.go
Outdated
|
|
||
| keyPtr, err := syscall.UTF16PtrFromString(fullPath) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("error converting key path: %v", err) |
There was a problem hiding this comment.
In RemoveAllowlistValueNames, the UTF16PtrFromString conversion error is formatted with %v, which prevents unwrapping and is inconsistent with the rest of the new error-wrapping style (and with callers using errors.Is()). Use %w here as well so the underlying error can be matched/inspected by callers.
| return nil, fmt.Errorf("error converting key path: %v", err) | |
| return nil, fmt.Errorf("error converting key path: %w", err) |
After adding extensions to a blocklist, remove them from the matching browser's allowlist. Chrome, Edge, and Firefox lists are checked independently since extension IDs are not shared across browsers. Runs at startup (after ProcessExistingPolicies) and on every live forcelist detection (PrintDiff). Allowlist keys that become empty after cleanup are deleted.
After adding extensions to a blocklist, remove them from the matching browser's allowlist. Chrome, Edge, and Firefox lists are checked independently since extension IDs are not shared across browsers.
Runs at startup (after ProcessExistingPolicies) and on every live forcelist detection (PrintDiff). Allowlist keys that become empty after cleanup are deleted.