Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,7 @@ block** — off by default, best-effort, and unable to change the baseline verdi
"integrity_check_on_restart": false,
"scanner_registry_url": "",
"tpa_bundle_path": "",
"auto_baseline_scan": true,
"deep_scan": {
"enabled": false,
"fetch_package_source": true,
Expand All @@ -653,6 +654,7 @@ block** — off by default, best-effort, and unable to change the baseline verdi
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `tpa_bundle_path` | string | `""` (embedded) | Filesystem path to the tpa-db `scanner-bundle.json` the offline TPA scanner runs. Empty uses the corpus embedded in the build. Env override: `MCPPROXY_TPA_BUNDLE_PATH`, which wins over this field on every path (loader, hot-reload, `/api/v1/config/apply`). Re-read on config hot-reload and honoured in every transport, stdio included. A bundle that fails to read/parse/version-check/compile — or that contributes zero runnable rules — is refused and the previously active corpus stays live; the reason is surfaced as `signature_bundle.load_error` in `GET /api/v1/security/overview` and in `mcpproxy security overview`. |
| `auto_baseline_scan` | boolean | `true` | Kill switch for the **automatic informational baseline scan**. When on (the default), every newly added server gets one free in-process Pass-1 TPA scan, and once per installation a background sweep scans pre-existing enabled servers that have never been scanned (marker persisted in BBolt, so it runs exactly once and never delays startup). The result only populates the security badge / scan summary: it **never** quarantines, approves, or otherwise gates a server. Disabled servers are skipped, and so are `trust_mode: "scan"` servers — that mode's own admission gate scans them and auto-approves on a clean verdict, so the informational path stays out of it entirely rather than risk feeding that gate; this flag does not affect that separate path. Set to `false` to suppress all automatic scans; manual scans keep working. Hot-reloadable — the flag is read live at each decision point. Env override: `MCPPROXY_AUTO_BASELINE_SCAN` (`true`/`1`/`false`/`0`), which wins over this field on every path. |
| `deep_scan.enabled` | boolean | `false` | Master opt-in for the heavy layer. When `false`, no Docker scanner runs and no source extraction is attempted — only the in-process baseline scanner executes. |
| `deep_scan.fetch_package_source` | boolean | `true` (when deep scan is on) | Whether the scanner fetches (never executes) the published source of `npx`/`uvx` package-runner servers when no local source is available. Set `false` for air-gapped deployments. |
| `deep_scan.disable_no_new_privileges` | boolean | `false` | Omits `--security-opt no-new-privileges` from scanner container runs (snap-docker/AppArmor escape hatch). |
Expand Down
19 changes: 19 additions & 0 deletions docs/features/security-quarantine.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,25 @@ a full-config apply is never blocked by a legacy value it did not introduce.
Should an unvalidated value ever reach the runtime anyway, resolution still
fails closed to `manual`.

### Automatic informational baseline scan

Independently of `trust_mode`, MCPProxy runs the free in-process Pass-1 TPA scan
so every server ends up with a security verdict instead of an empty badge:

- **On admission** — a newly added, enabled server gets one baseline scan. Servers
with `trust_mode: "scan"` are skipped entirely: that mode's own admission gate
scans them, and routing an informational verdict into its settle-driven
auto-approval could change quarantine state.
- **Once per installation** — a background sweep at startup scans enabled servers
that have never been scanned (for installs that predate this behaviour). It is
serialized, never delays startup, is cancelled on shutdown, and a persisted
marker keeps it one-shot.

These scans are **informational**: the verdict fills in the scan summary and the
UI badge and never quarantines, approves, or blocks anything. Disable with
`security.auto_baseline_scan: false` (env: `MCPPROXY_AUTO_BASELINE_SCAN`).
The `trust_mode: "scan"` gate above is a separate path and is unaffected.

### Signature bundle (offline TPA corpus)

The `scan` mode runs an offline TPA signature corpus (the tpa-db
Expand Down
89 changes: 89 additions & 0 deletions internal/config/auto_baseline_scan_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package config

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestIsAutoBaselineScanEnabled(t *testing.T) {
boolPtr := func(b bool) *bool { return &b }

t.Run("nil security block defaults to enabled", func(t *testing.T) {
var sec *SecurityConfig
assert.True(t, sec.IsAutoBaselineScanEnabled())
})

t.Run("unset field defaults to enabled", func(t *testing.T) {
assert.True(t, (&SecurityConfig{}).IsAutoBaselineScanEnabled())
})

t.Run("explicit false disables", func(t *testing.T) {
assert.False(t, (&SecurityConfig{AutoBaselineScan: boolPtr(false)}).IsAutoBaselineScanEnabled())
})

t.Run("explicit true enables", func(t *testing.T) {
assert.True(t, (&SecurityConfig{AutoBaselineScan: boolPtr(true)}).IsAutoBaselineScanEnabled())
})

t.Run("env override outranks the file value", func(t *testing.T) {
t.Setenv(EnvAutoBaselineScan, "false")
assert.False(t, (&SecurityConfig{AutoBaselineScan: boolPtr(true)}).IsAutoBaselineScanEnabled())

t.Setenv(EnvAutoBaselineScan, "0")
assert.False(t, (&SecurityConfig{}).IsAutoBaselineScanEnabled())

t.Setenv(EnvAutoBaselineScan, "true")
assert.True(t, (&SecurityConfig{AutoBaselineScan: boolPtr(false)}).IsAutoBaselineScanEnabled())

t.Setenv(EnvAutoBaselineScan, "1")
assert.True(t, (&SecurityConfig{AutoBaselineScan: boolPtr(false)}).IsAutoBaselineScanEnabled())
})

t.Run("unrecognized env value is ignored", func(t *testing.T) {
t.Setenv(EnvAutoBaselineScan, "maybe")
assert.False(t, (&SecurityConfig{AutoBaselineScan: boolPtr(false)}).IsAutoBaselineScanEnabled())
assert.True(t, (&SecurityConfig{}).IsAutoBaselineScanEnabled())
})
}

// The loader's env pass must use the SAME vocabulary as the accessor. A bare
// non-empty check there would materialize AutoBaselineScan=false for a typo like
// "yes", and because the accessor then ignores the unrecognized env value it
// would read that overwritten false — silently disabling automatic scanning for
// a config that had explicitly enabled it.
func TestAutoBaselineScanEnvOverride_LoaderVocabulary(t *testing.T) {
boolPtr := func(b bool) *bool { return &b }

t.Run("unrecognized value leaves the configured field alone", func(t *testing.T) {
t.Setenv(EnvAutoBaselineScan, "yes")
cfg := &Config{Security: &SecurityConfig{AutoBaselineScan: boolPtr(true)}}
applyTLSEnvOverrides(cfg)
require.NotNil(t, cfg.Security.AutoBaselineScan)
assert.True(t, *cfg.Security.AutoBaselineScan)
assert.True(t, cfg.Security.IsAutoBaselineScanEnabled())
})

t.Run("unrecognized value does not materialize a security block", func(t *testing.T) {
t.Setenv(EnvAutoBaselineScan, "maybe")
cfg := &Config{}
applyTLSEnvOverrides(cfg)
assert.Nil(t, cfg.Security)
})

t.Run("recognized values still override and materialize the block", func(t *testing.T) {
t.Setenv(EnvAutoBaselineScan, "false")
cfg := &Config{}
applyTLSEnvOverrides(cfg)
require.NotNil(t, cfg.Security)
require.NotNil(t, cfg.Security.AutoBaselineScan)
assert.False(t, *cfg.Security.AutoBaselineScan)

t.Setenv(EnvAutoBaselineScan, "1")
cfg = &Config{Security: &SecurityConfig{AutoBaselineScan: boolPtr(false)}}
applyTLSEnvOverrides(cfg)
require.NotNil(t, cfg.Security.AutoBaselineScan)
assert.True(t, *cfg.Security.AutoBaselineScan)
})
}
46 changes: 46 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -1944,6 +1944,14 @@ func IsValidTrustMode(s string) bool {
// loader's env pass.
const EnvTPABundlePath = "MCPPROXY_TPA_BUNDLE_PATH"

// EnvAutoBaselineScan is the environment kill-switch for the automatic,
// informational Pass-1 baseline scan (`security.auto_baseline_scan`). Like the
// bundle path, precedence is enforced in the accessor
// (SecurityConfig.IsAutoBaselineScanEnabled) rather than only in the loader, so
// a config posted to /api/v1/config/apply cannot defeat the operator's env
// setting. Accepts "true"/"1" and "false"/"0"; any other value is ignored.
const EnvAutoBaselineScan = "MCPPROXY_AUTO_BASELINE_SCAN"

// TrustModeNormalization records one per-server trust_mode value that the load
// path rewrote because it was not in the accepted vocabulary.
type TrustModeNormalization struct {
Expand Down Expand Up @@ -2758,6 +2766,21 @@ type SecurityConfig struct {
// baseline scanner runs. A deep-scan failure NEVER changes the baseline verdict
// (FR-007/FR-008).
DeepScan *DeepScanConfig `json:"deep_scan,omitempty" mapstructure:"deep-scan"`

// AutoBaselineScan is the kill-switch for the AUTOMATIC, informational
// Pass-1 baseline scan: the free in-process TPA scan mcpproxy runs for every
// newly admitted server (any trust mode) and, once per installation, over
// pre-existing servers that have never been scanned.
//
// Informational ONLY: the resulting verdict populates the security badge and
// the scan summary, and NEVER gates quarantine or approval. The
// trust_mode:"scan" admission gate is a separate path and is unaffected by
// this flag.
//
// Default (nil) is ENABLED. Set to false to suppress every automatic scan
// (manual scans keep working). Env override: MCPPROXY_AUTO_BASELINE_SCAN,
// which wins over this field on every path.
AutoBaselineScan *bool `json:"auto_baseline_scan,omitempty" mapstructure:"auto-baseline-scan" swaggertype:"boolean"`
}

// DeepScanConfig configures the opt-in "deep scan" layer (Spec 077 US3):
Expand Down Expand Up @@ -2813,6 +2836,29 @@ func (sc *SecurityConfig) EffectiveTPABundlePath() string {
return sc.TPABundlePath
}

// IsAutoBaselineScanEnabled reports whether mcpproxy may run the automatic,
// informational Pass-1 baseline scan (new-server admission scan + the one-shot
// baseline sweep). Default is ENABLED: a nil SecurityConfig, or an unset
// auto_baseline_scan, means on — the scan is free, in-process, and drives no
// gating, so an install that never touched the security block still gets its
// badges populated.
//
// MCPPROXY_AUTO_BASELINE_SCAN outranks the file value on every path (loader,
// hot-reload, /api/v1/config/apply) because the precedence is resolved here
// rather than only in the loader's env pass.
func (sc *SecurityConfig) IsAutoBaselineScanEnabled() bool {
switch os.Getenv(EnvAutoBaselineScan) {
case "true", "1":
return true
case "false", "0":
return false
}
if sc == nil || sc.AutoBaselineScan == nil {
return true
}
return *sc.AutoBaselineScan
}

// DeepScanScanners returns the optional per-scanner allow-list for the deep-scan
// layer, or nil when unset (all enabled deep scanners are eligible).
func (sc *SecurityConfig) DeepScanScanners() []string {
Expand Down
26 changes: 26 additions & 0 deletions internal/config/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const (
DefaultDataDir = ".mcpproxy"
ConfigFileName = "mcp_config.json"
trueValue = "true"
falseValue = "false"
)

// LoadFromFile loads configuration from a specific file
Expand Down Expand Up @@ -684,6 +685,31 @@ func applyTLSEnvOverrides(cfg *Config) {
cfg.Security.TPABundlePath = value
}

// Override the automatic informational baseline-scan kill switch from
// environment. Materializes the security block so an install with no
// `security` key can still be opted out (or explicitly back in).
// IsAutoBaselineScanEnabled re-reads the same variable, so the env value
// also wins on paths that never pass through the loader.
// Only the documented vocabulary overrides. An unrecognized value (typo,
// "yes", "maybe") must be IGNORED, matching IsAutoBaselineScanEnabled — a
// bare `value != ""` check would have materialized `false` here and silently
// turned automatic scanning off for a config that had explicitly enabled it,
// because the accessor then reads the overwritten field rather than the env.
switch os.Getenv(EnvAutoBaselineScan) {
case trueValue, "1":
enabled := true
if cfg.Security == nil {
cfg.Security = &SecurityConfig{}
}
cfg.Security.AutoBaselineScan = &enabled
case falseValue, "0":
enabled := false
if cfg.Security == nil {
cfg.Security = &SecurityConfig{}
}
cfg.Security.AutoBaselineScan = &enabled
}

// Override retrieve_tools serialization mode from environment (Spec 085).
// Explicit MCPPROXY_* alias per the established loader convention; the
// value is validated by cfg.Validate() right after these overrides apply.
Expand Down
10 changes: 10 additions & 0 deletions internal/security/scanner/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -1148,6 +1148,16 @@ func (s *Service) StartScan(ctx context.Context, serverName string, dryRun bool,
}
job, err := s.engine.StartScan(ctx, req, callback)
if err != nil {
// The callback owns resolvedCleanup, but the engine only ever invokes
// the callback for a scan it ACCEPTED. Every rejection path here —
// "scan already in progress", scanner resolution failure, no scanners
// installed — returns before OnScanStarted, so the temp source
// directory prepared above would be orphaned on disk. Release it on the
// way out; the automatic baseline paths retry, and the concurrent-scan
// rejection is exactly what they hit when they race a manual scan.
if resolvedCleanup != nil {
resolvedCleanup()
}
return nil, err
}

Expand Down
31 changes: 31 additions & 0 deletions internal/server/scan_admission_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,28 @@ type fakeSecurityScanner struct {
summaries map[string]*scanner.ScanSummary
hasBaseline map[string]bool
approveErr error
// scanResult is the summary a StartScan publishes for a server, mimicking
// the real service where a completed scan makes GetScanSummary non-nil.
// Absent ⇒ the scan leaves the summary nil.
scanResult map[string]*scanner.ScanSummary
startScanErr error
// startScanErrByServer fails StartScan for specific servers only, so a
// PARTIALLY failing sweep can be exercised. Takes precedence over
// startScanErr for the servers it names.
startScanErrByServer map[string]error

approveCalls []string
startScanCalls []string
// startScanTries records EVERY StartScan entry, including the ones that
// return startScanErr, so retry-capping can be asserted.
startScanTries []string
}

func newFakeSecurityScanner() *fakeSecurityScanner {
return &fakeSecurityScanner{
summaries: map[string]*scanner.ScanSummary{},
hasBaseline: map[string]bool{},
scanResult: map[string]*scanner.ScanSummary{},
}
}

Expand All @@ -57,7 +70,18 @@ func (f *fakeSecurityScanner) ApproveServer(_ context.Context, serverName string
func (f *fakeSecurityScanner) StartScan(_ context.Context, serverName string, _ bool, _ []string, _ string) (*scanner.ScanJob, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.startScanTries = append(f.startScanTries, serverName)
if err, ok := f.startScanErrByServer[serverName]; ok {
return nil, err
}
if f.startScanErr != nil {
return nil, f.startScanErr
}
f.startScanCalls = append(f.startScanCalls, serverName)
// Mirror the real service: a scan that ran leaves a readable summary behind.
if result, ok := f.scanResult[serverName]; ok {
f.summaries[serverName] = result
}
return nil, nil
}

Expand All @@ -83,6 +107,13 @@ func (f *fakeSecurityScanner) startedScans() []string {
return append([]string(nil), f.startScanCalls...)
}

// startScanAttempts counts every StartScan entry, failures included.
func (f *fakeSecurityScanner) startScanAttempts() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.startScanTries...)
}

// newAdmissionTestServer builds a Server whose runtime config carries the given
// servers and whose securityScanner is the supplied fake. The event loop is NOT
// started — tests drive the admission methods directly.
Expand Down
Loading
Loading