Skip to content

Commit 61d78da

Browse files
feat(scanner): fetch published npx/uvx package source for scanning (MCP-2206) (#658)
* feat(scanner): fetch published npx/uvx package source for scanning (MCP-2206) Package-runner servers (npx/uvx) are the primary quarantine/scan target, but a server quarantined on add is never run locally, so the local package cache misses and the scan degraded to tool_definitions_only — no real source-level analysis for exactly the untrusted code that most needs it. Add a resolution fallback that fetches the PUBLISHED package source without executing it, wired into both Pass 1 (Resolve) and Pass 2 (ResolveFullSource): - npm (npx): `npm pack <spec> --ignore-scripts` downloads the published tarball with no lifecycle scripts; extracted as source_method=npm_pack. - PyPI (uvx): `uv pip download --no-deps` (fallback `pip download`) fetches the wheel (preferred) or sdist; unpacked without building/setup.py as source_method=pip_download. Security: a scanner must never execute the code it scans. The fetch only downloads + unpacks archives. Extraction is hardened against path traversal (zip-slip), symlink escape, and decompression bombs (bounded file count and total size). On missing toolchain / offline / fetch failure it falls through to tool_definitions_only with no regression. Enabled by default; air-gapped deployments can disable network egress via security.scanner_fetch_package_source=false. Co-Authored-By: Paperclip <noreply@paperclip.ing> * docs(oas): regenerate OpenAPI for scanner_fetch_package_source field (MCP-2206) Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(security): force --only-binary on python package fetch — never build/execute sdist (MCP-2391) A plain 'pip download' / 'uv pip download' of an sdist invokes the package's PEP 517 build backend (setup.py egg_info) to resolve metadata, which executes code from the package being scanned — violating the download-and-unpack-NEVER-execute invariant of the source fetch. - Add --only-binary=:all: to uvDownloadArgs and pipDownloadArgs so only a prebuilt wheel is ever fetched; a package with no wheel fails the fetch and falls back to tool-definitions-only. - Refuse a non-wheel archive in fetchPythonPackage (defense-in-depth); drop the sdist extraction branch. - Add positive tests asserting --only-binary=:all: on both Python builders (the prior NoExecution tests only asserted absence of verbs). - Align comments + docs (security-scanner-plugins.md, swagger.yaml). Co-Authored-By: Paperclip <noreply@paperclip.ing> --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1 parent 9f4930c commit 61d78da

10 files changed

Lines changed: 891 additions & 4 deletions

File tree

docs/features/security-scanner-plugins.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -241,10 +241,24 @@ Scanners communicate results via SARIF 2.1.0. Exit code `0` indicates "scan comp
241241
2. Package cache (npx/uvx/pipx/bunx) — PREFERRED for package runners
242242
3. WorkingDir (from server config)
243243
4. Arg-scan fallback — accepts only directories containing source markers
244-
5. Tool definitions only — last resort (HTTP / SSE / unresolvable)
244+
5. Published package fetch (npx/uvx) — download & unpack real source, no execution
245+
6. Tool definitions only — last resort (HTTP / SSE / unresolvable)
245246
```
246247

247-
The resolved method and path are recorded on the scan job and visible via both the text and JSON report. See [Security Commands → scan](/cli/security-commands#security-scan) for more.
248+
The resolved method and path are recorded on the scan job and visible via both the text and JSON report. The `source_method` field reports how source was obtained: `docker_extract`, `npx_cache`, `uvx_cache`, `working_dir`, `npm_pack`, `pip_download`, `url`, or `tool_definitions_only`. See [Security Commands → scan](/cli/security-commands#security-scan) for more.
249+
250+
#### Published package fetch (npx / uvx)
251+
252+
Package-runner servers (`npx`, `uvx`) are the **primary** quarantine/scan target, but a server quarantined on add has never run locally — so the local package cache (step 2) misses and, before this fallback existed, the scan degraded to **tool definitions only** (no real source-level analysis). Step 5 closes that gap by downloading the *published* package source so the AI and supply-chain scanners run against real code.
253+
254+
**The source is fetched but never executed.** A scanner must not run the untrusted code it is scanning. The fetch only ever downloads and unpacks archives:
255+
256+
- **npm (`npx`)**`npm pack <pkg>@<version> --ignore-scripts` downloads the published tarball without running any lifecycle scripts (`install`/`postinstall`), then it is extracted (`source_method=npm_pack`).
257+
- **PyPI (`uvx`)**`uv pip download <pkg>==<version> --no-deps --only-binary=:all:` (falling back to `pip download`) fetches **only a prebuilt wheel**, which is unpacked without building or running `setup.py` (`source_method=pip_download`). `--only-binary=:all:` is mandatory: downloading an sdist would invoke the package's PEP 517 build backend (`setup.py egg_info`) to resolve metadata, executing the untrusted code. A package that ships **no wheel** therefore fails the fetch and falls back to tool definitions only — sdists are never built or extracted.
258+
259+
Extraction is hardened against path traversal (zip-slip), symlink escape, and decompression bombs (bounded file count and total size). If the toolchain is missing, the host is offline, or the fetch fails, resolution falls through to **tool definitions only** with no regression.
260+
261+
This fallback is **enabled by default**. Air-gapped deployments that must forbid the scanner's network egress can disable it with `security.scanner_fetch_package_source: false` — package-runner servers without local source then scan tool definitions only.
248262

249263
## SARIF normalization
250264

internal/config/config.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1768,4 +1768,26 @@ type SecurityConfig struct {
17681768
// is small. The preferred fix remains replacing snap docker with a
17691769
// distro-packaged docker.
17701770
ScannerDisableNoNewPrivileges bool `json:"scanner_disable_no_new_privileges,omitempty" mapstructure:"scanner-disable-no-new-privileges"`
1771+
1772+
// ScannerFetchPackageSource controls whether the scanner fetches the
1773+
// PUBLISHED source of package-runner servers (npx/uvx) — without executing
1774+
// it — when no local source is available (no Docker container, no local
1775+
// package cache, no working_dir). This is the primary quarantine/scan
1776+
// target: a quarantined-on-add server is never run locally, so without this
1777+
// the scan degrades to tool-definitions-only (no real source-level
1778+
// analysis). See MCP-2206.
1779+
//
1780+
// Fetching uses `npm pack --ignore-scripts` (npm) and `uv pip download` /
1781+
// `pip download` with `--only-binary=:all:` (Python), which only download +
1782+
// unpack archives and NEVER run install, build, or setup.py — a scanner must
1783+
// not execute the untrusted code it is scanning. The Python
1784+
// `--only-binary=:all:` flag is required because downloading an sdist would
1785+
// invoke its build backend (setup.py); packages with no wheel fall back to
1786+
// tool-definitions-only instead. Extraction is hardened against path
1787+
// traversal and decompression bombs.
1788+
//
1789+
// Default (nil) is ENABLED. Set to false on air-gapped deployments to
1790+
// forbid the scanner's network egress; such servers then fall back to the
1791+
// tool-definitions-only scan with no regression.
1792+
ScannerFetchPackageSource *bool `json:"scanner_fetch_package_source,omitempty" mapstructure:"scanner-fetch-package-source"`
17711793
}

0 commit comments

Comments
 (0)