Skip to content

fix(security): eliminate live-pointer data races in scanner registry and engine - #1038

Open
Dumbris wants to merge 2 commits into
mainfrom
fix/scanner-registry-engine-races
Open

fix(security): eliminate live-pointer data races in scanner registry and engine#1038
Dumbris wants to merge 2 commits into
mainfrom
fix/scanner-registry-engine-races

Conversation

@Dumbris

@Dumbris Dumbris commented Aug 24, 2026

Copy link
Copy Markdown
Member

Fixes two pre-existing data races in internal/security/scanner, flagged during the #1032 review as carried-forward defects, plus two activeScans lifecycle defects that cross-model review surfaced in the same seam.

1. Registry handed out live pointers

Registry.Get/List returned the live *ScannerPlugin records the registry keeps, while InstallScanner, ConfigureScanner and syncRegistryFromStorage wrote Status / ConfiguredEnv / ImageOverride straight onto them outside Registry.mu — a real race against every concurrent reader, including GET /api/v1/security/scanners.

  • Get/List now return defensive copies (ScannerPlugin.clone).
  • All mutation routes through locked registry methods: existing UpdateStatus plus new SetConfiguredEnv and SetRuntimeConfig (env + image override in one locked update, so a reader never sees new env against the old image).
  • loadBundledRegistry clones the package-level bundledScanners records instead of stamping Status onto memory shared by every Registry in the process.
  • Register stores its own copy so a caller keeping its pointer can't reach into registry state.

2. Engine wrote job state on a live job

executeScan wrote job.Status / Error / CompletedAt without holding e.mu while the job was still in activeScans, so GetActiveJob (→ GetScanStatus → REST scan-status handler, which JSON-encodes it) read the same record.

  • The cancellation check, terminal-status decision and write now happen in a single locked section.
  • Every job handed outside the engine is a snapshot: GetActiveJob, StartScan's return value, and the scan callbacks — which persist and JSON-encode the job while sibling scanner goroutines are still updating ScannerStatuses under the lock.
  • ScanContext is shared by pointer on purpose (populated before job creation, never written again); documented on ScanJob.clone.

3. activeScans slot could be handed to the wrong scan

Found by cross-model review of the above, in the same invariant.

  • A cancelled scan's unwind evicted its replacement. executeScan's cleanup deleted the slot by server name alone. CancelScan drops a job from activeScans immediately while its scanner goroutines keep running, so a replacement scan for the same server can take the slot before the cancelled scan unwinds — and that cleanup then deleted the replacement. With the slot empty, StartScan would accept a second concurrent scan of the same server and GetScanStatus would report "no active scan" while one was running. Cleanup now goes through Engine.clearActiveJob, which releases the slot only when the stored pointer is still the job being torn down.
  • CancelScan accepted an already-settled job. A job stays in activeScans for a short window after executeScan writes its terminal status — the completion callback persists the report and emits the completion event inside it. A cancel arriving there returned success and flipped Status to cancelled while the already-cloned completed job was still on its way to storage, so the API reported a cancellation the stored job contradicted. Cancelling a settled job now fails and leaves the terminal status intact.

Both are reachable from POST /api/v1/servers/{name}/scan/cancel.

Key decisions

  • Registry Get returning a copy means the implicit "mutate the returned record and the engine picks it up" side effect is gone; the two call sites that relied on it (InstallScanner reusing stored env/override, ConfigureScanner) now write back explicitly through the locked setter. No behavior change.
  • Clones preserve nil slices/maps so JSON shapes (omitempty) are byte-identical.
  • The settled job deliberately stays in activeScans until clearActiveJob runs, so GetScanSummary keeps answering "scanning" until the completion callback has persisted the report — the window awaitScanVerdict (MCP scan_server) documents and depends on.
  • No API shapes or external behavior changed, other than a cancel of an already-finished scan now returning an error instead of a false success.

Deferred (pre-existing, out of scope)

Cross-model review also raised three items that are byte-identical to main and belong to different seams; filing separately rather than widening a race-fix PR:

  • Engine config hot-reload (SetDeepScan / SetIsolationMode / SetScannerDisableNoNewPrivileges) writes deepScanEnabled, deepScanScanners, isolationMode and disableNoNewPrivileges with no synchronization against a running scan. A correct fix has to cover the runSingleScanner reader too.
  • syncRegistryFromStorage restores ConfiguredEnv but not ImageOverride, so a custom scanner image is lost from the registry across a restart until the scanner is reconfigured.
  • RemoveScanner only resets Status; the registry record keeps ConfiguredEnv / ImageOverride for the process lifetime.

Tests

New -race stress tests (extending the #1032 TestGetOverview_ConcurrentScannerStatusUpdateIsRaceFree pattern), all of which reproduce the races on main:

  • registry_race_test.go — Get/List copy contracts; List/Get/InProcessRunnableIDs vs UpdateStatus/SetRuntimeConfig/SetConfiguredEnv; InstallScanner vs ListScanners+GetOverview; ConfigureScanner vs registry readers.
  • engine_race_test.goGetActiveJob snapshot contract; GetActiveJob+JSON-encode hammered against 25 scan completions; callbacks receive snapshots.
  • engine_activescans_test.goclearActiveJob evicts only its own job across a cancel + replacement; CancelScan rejects a settled job and preserves its terminal status; the running-job cancel path still works.

Verified: go test -race ./internal/security/scanner (also -count=2), go test -race -skip 'E2E|Binary|MCPProtocol|...' ./internal/server (226s, ok), ./internal/httpapi, ./cmd/scan-eval, both edition builds, golangci-lint v2 clean.

…and engine

Two pre-existing races carried forward from the #1032 review.

Registry: Get/List handed out the live *ScannerPlugin records the registry
keeps, while InstallScanner/ConfigureScanner/syncRegistryFromStorage wrote
Status, ConfiguredEnv and ImageOverride straight onto them outside the lock —
racing every concurrent reader, including GET /api/v1/security/scanners.
Get/List now return defensive copies and every mutation goes through a locked
method (UpdateStatus, new SetConfiguredEnv/SetRuntimeConfig), following the
InProcessRunnableIDs pattern. loadBundledRegistry also clones the package-level
bundled records instead of stamping Status onto memory shared by every Registry
in the process.

Engine: executeScan wrote job.Status/Error/CompletedAt without holding e.mu
while the job was still in activeScans, so GetActiveJob (REST scan-status, which
JSON-encodes it) read a live record. The terminal-status decision and write now
happen in one locked section, and every job handed outside the engine —
GetActiveJob, StartScan's return value, and the scan callbacks (which persist
and encode the job while sibling scanner goroutines still update
ScannerStatuses) — is a snapshot.

No API shapes or external behavior change.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 24, 2026

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 0de2269
Status: ✅  Deploy successful!
Preview URL: https://ba644ebf.mcpproxy-docs.pages.dev
Branch Preview URL: https://fix-scanner-registry-engine.mcpproxy-docs.pages.dev

View logs

… completion

Cross-model review of the registry/engine race fix found two more defects in
the same activeScans invariant.

executeScan's cleanup deleted the slot by server name alone. CancelScan drops a
job from activeScans immediately while its scanner goroutines keep running, so a
replacement scan for the same server can take the slot before the cancelled scan
unwinds — and that cleanup then evicted the REPLACEMENT. With the slot empty,
StartScan would accept a second concurrent scan of the same server and
GetScanStatus would report "no active scan" while one was running. The cleanup
now runs through Engine.clearActiveJob, which releases the slot only if the
job that owns it is still the one being torn down.

CancelScan accepted a job that had already reached a terminal status. A job
stays in activeScans for a short window after executeScan writes Status/Error/
CompletedAt — the completion callback persists the report and emits the
completion event inside it. A cancel arriving there returned 200 and flipped
Status to cancelled while the already-cloned COMPLETED job was still being
persisted, so the API reported a cancellation the stored job contradicted.
Cancelling a settled job now fails instead, leaving the terminal status intact.

Both are reachable from POST /api/v1/servers/{name}/scan/cancel.
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

📦 Build Artifacts

Workflow Run: View Run
Branch: fix/scanner-registry-engine-races

Available Artifacts

  • archive-darwin-amd64 (29 MB)
  • archive-darwin-arm64 (26 MB)
  • archive-linux-amd64 (17 MB)
  • archive-linux-arm64 (15 MB)
  • archive-windows-amd64 (29 MB)
  • archive-windows-arm64 (25 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (23 MB)
  • installer-dmg-darwin-arm64 (20 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 32765287606 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 82.79570% with 16 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/security/scanner/engine.go 82.05% 4 Missing and 3 partials ⚠️
internal/security/scanner/registry.go 84.00% 2 Missing and 2 partials ⚠️
internal/security/scanner/types.go 84.61% 2 Missing and 2 partials ⚠️
internal/security/scanner/service.go 66.66% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants