From 94781d6bdaf85c6d535ede9d24742220a6aa9cd3 Mon Sep 17 00:00:00 2001 From: Sahil Bansal Date: Wed, 22 Jul 2026 15:19:19 +0530 Subject: [PATCH] Remove guard mode: proxy interception is now the only flow (#386) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: remove guard mode execution paths and guard-only packages Guard (non-proxy) mode is removed; all package-manager commands now always run the proxy flow. Removes the guard engine, the common flow, the extractor package, the npm/pypi dependency resolvers and the PackageResolver plumbing that only guard mode consumed. The guard package retains only PackageManagerGuardInteraction, which the proxy flow and confirmation interceptors reuse for user prompts. Proxy behavior is unchanged. * refactor: remove proxy opt-out surfaces, guard references in config, action and docs Removes Config.ProxyMode, ProxyConfig.Enabled, IsProxyModeEnabled, the proxy_mode legacy fallback, PMG_PROXY_ENABLED handling and the --proxy-mode / --include-dev-dependencies flags. Proxy interception can no longer be disabled. Also removes the proxy-mode input from the GitHub Action, the proxy-mode doctor check and setup info row, updates the E2E workflow to stop passing --proxy-mode=false, and sweeps guard-mode wording from docs and the config template. The legacy proxy_install_only flat key and PMG_PROXY_INSTALL_ONLY env var remain supported. audit.FlowTypeGuard is kept so previously recorded audit events still translate for cloud sync. * feat: fail loudly when a removed proxy opt-out is still configured A leftover proxy.enabled: false / proxy_mode: false config key or PMG_PROXY_ENABLED=false / PMG_PROXY_MODE=false env var previously meant guard mode; silently ignoring it would switch those users to proxy interception without notice. PMG now exits with an actionable error naming the exact source. Precedence mirrors the old resolution order: env (ignored under lockdown) > proxy.enabled > legacy proxy_mode. The pmg config subtree is exempt so the config file can still be fixed with pmg config edit/set. The GitHub Action's proxy-mode input is kept as a tombstone that fails the action when set to false and warns otherwise. * refactor: extract flows.RunProxy and address review findings Collapses the identical parse-then-run body duplicated across the 12 package manager commands into flows.RunProxy. Documents the cache-hit / offline analysis trade-off versus the removed guard manifest path, fixes a stale non-proxy label in the E2E workflow and a stale guard reference in the uvx parser comment. * fix(config): mirror old proxy opt-out precedence exactly PMG_PROXY_MODE only ever took effect through the legacy fallback, which was gated on the presence of a proxy: key in the config file (even a null one). Promoting it to the top env tier caused two inversions: a stale PMG_PROXY_MODE=false hard-failed configs that resolved to proxy mode, and PMG_PROXY_MODE=true silently overrode an explicit proxy.enabled: false file opt-out. The check now resolves in the old order: PMG_PROXY_ENABLED > proxy: section (presence gates the legacy tier) > PMG_PROXY_MODE > flat proxy_mode. parseOptOutBool also accepts numeric values (0 = false) to match viper's WeaklyTypedInput/cast.ToBool coercion, so proxy.enabled: 0 and proxy_mode: 0 are detected as opt-outs. * refactor: move package manager interaction out of guard * refactor: trim package manager interaction * fix(config): normalize config keys viper-style in proxy opt-out check Viper resolved config file keys case-insensitively and expanded dotted keys, so spellings like Proxy:, Enabled:, a literal proxy.enabled key or Proxy_Mode selected guard mode before the removal. The opt-out check now lowercases keys recursively and nests dotted keys before matching, so those existing opt-outs fail loudly instead of being silently ignored. * refactor: remove inert transitive controls, dead parser state and guard audit variant transitive / transitive_depth lost their only consumers with the dependency resolvers; remove the config fields, flags, template and doc entries, and the report/audit plumbing that misreported transitive analysis as enabled. Remove write-only parser state (PackageInstallTarget.Extras, ParsedCommand.ManifestFiles, ShouldExtractFromManifest); IsManifestInstall stays as it feeds sandbox gating via IsInstallationCommand. Remove audit.FlowTypeGuard and its cloud mapping; guard events recorded by pre-removal versions in an unsynced WAL translate to UNSPECIFIED. * fix: address review findings on the opt-out wiring and cleanups Move the removed-opt-out rejection from the CLI PersistentPreRun into proxyFlow.Run: the check now fires exactly for package-manager runs, so non-install commands (pmg setup remove, doctor, config, version) stay usable to fix or remove an opted-out installation, and future commands inherit or avoid the check by construction instead of by exemption list. Also: make the e2e malicious-package assertion actually fail the job when an install is not blocked, route pmg go through flows.RunProxy, and drop the dead extras return from pypiParsePackageInfo (extras are still stripped from package names). * fix(config): make the removed opt-out check faithful to the old resolution The gate that silenced the legacy proxy_mode surfaces matched the raw proxy key case-sensitively in the old code, while values resolved viper-style (case-insensitive, dotted keys); applying each semantic where the old code did fixes both divergences: a case-variant Proxy: section no longer hides a flat proxy_mode: false opt-out, and a dotted proxy.enabled: false overridden by proxy_mode: true no longer errors. Replace the generic key-tree normalization with two targeted lookups (the check only ever resolves proxy.enabled and proxy_mode), which also makes colliding spellings resolve deterministically. Coerce legacy-tier values cast.ToBool-style so PMG_PROXY_MODE=off style opt-outs are detected, log the config read error instead of swallowing it, and shorten the error to a one-line statement with the specific remedy in the help text. Add lockdown coverage (env inert both directions) and a repeated-run determinism test. * fix(config): fall back to defaults for unrecognized proxy opt-out values The old loader swallowed viper errors and ran on defaults, so values like proxy.enabled: yes or PMG_PROXY_ENABLED=banana silently discarded the whole config and defaulted to proxy. Treat them the same way now: unrecognized values mean the default (proxy on) instead of a hard error, and the doc comment no longer claims the old loader failed loudly. Only values that actually meant guard mode fail. Also check the removed opt-out before the CA trust check in pmg go, restoring the old error precedence: a config problem must not steer the user into an unnecessary OS trust store change. * fix(e2e): PMG_PROXY_MODE assertion must match the legacy gate semantics The runner's setup step writes the template config, which has a proxy: section — and with one present the legacy PMG_PROXY_MODE was always inert, so expecting a loud failure there asserts pre-fidelity-fix behavior. Assert both sides instead: inert (command succeeds) with the standard config, loud failure against an empty config dir where the legacy fallback actually applied. * refactor(config): collapse parseOptOutBool to ParseBool over the string form YAML hands us typed values (bool, int), so route them through fmt.Sprintf %v and strconv.ParseBool instead of a per-type switch. Identical behavior for every recognized value; numbers other than 0/1 now read as no opinion instead of cast.ToBool's nonzero-true, which no real config relies on. --- .github/workflows/pmg-e2e.yml | 138 ++++--- action.yml | 16 +- cmd/executors/npx.go | 23 +- cmd/executors/pipx.go | 24 +- cmd/executors/pnpx.go | 23 +- cmd/executors/uvx.go | 24 +- cmd/golang/go.go | 22 +- cmd/npm/bun.go | 23 +- cmd/npm/npm.go | 23 +- cmd/npm/pnpm.go | 24 +- cmd/npm/yarn.go | 23 +- cmd/pypi/pip.go | 25 +- cmd/pypi/pip3.go | 25 +- cmd/pypi/poetry.go | 24 +- cmd/pypi/uv.go | 24 +- cmd/setup/doctor.go | 19 - cmd/setup/info.go | 1 - config/cobra.go | 24 -- config/cobra_test.go | 5 +- config/config.go | 31 +- config/config.template.yml | 17 +- config/config_template_test.go | 6 - config/config_test.go | 38 +- config/managed_config_test.go | 6 +- config/proxy_optout.go | 139 +++++++ config/proxy_optout_test.go | 245 +++++++++++ config/trusted.go | 2 +- config/viper.go | 10 +- config/viper_env_test.go | 29 -- config/yaml_set_test.go | 17 +- docs/analysis-cache.md | 2 +- docs/config.md | 10 +- docs/dependency-cooldown.md | 2 +- docs/github-action.md | 4 +- docs/package-manager.md | 17 +- docs/proxy-mode.md | 18 +- docs/sandbox-landlock.md | 4 +- docs/sandbox.md | 2 +- errcodes/codes.go | 7 +- extractor/common.go | 91 ---- extractor/ecosystems.go | 60 --- extractor/extractor.go | 84 ---- extractor/npm.go | 79 ---- extractor/pypi.go | 81 ---- go.mod | 12 +- go.sum | 30 -- go.work.sum | 2 + guard/guard.go | 525 ------------------------ guard/guard_test.go | 265 ------------ internal/audit/audit.go | 1 - internal/audit/audit_test.go | 6 +- internal/audit/cloud_sink_test.go | 2 +- internal/audit/cloud_translate.go | 5 +- internal/audit/cloud_translate_test.go | 6 +- internal/audit/event.go | 2 - internal/flows/common_flow.go | 137 ------- internal/flows/outcome.go | 2 - internal/flows/proxy_flow.go | 36 +- internal/proxyserver/server.go | 1 - internal/ui/report.go | 23 +- main.go | 4 +- packagemanager/dependency_resolver.go | 229 ----------- packagemanager/errors.go | 17 - packagemanager/golang.go | 2 - packagemanager/golang_test.go | 26 +- packagemanager/noop_resolver.go | 25 -- packagemanager/npm.go | 2 - packagemanager/npm_resolver.go | 89 ---- packagemanager/npm_resolver_test.go | 167 -------- packagemanager/packagemanager.go | 49 +-- packagemanager/pypi.go | 51 +-- packagemanager/pypi_executor.go | 3 +- packagemanager/pypi_resolver.go | 254 ------------ packagemanager/pypi_resolver_test.go | 147 ------- packagemanager/pypi_test.go | 52 +-- packagemanager/pypi_uvx_executor.go | 4 +- proxy/interceptors/confirmation.go | 4 +- proxy/interceptors/confirmation_test.go | 6 +- sandbox/executor/apply.go | 2 +- test/proxye2e/confirm.go | 6 +- 80 files changed, 669 insertions(+), 3036 deletions(-) create mode 100644 config/proxy_optout.go create mode 100644 config/proxy_optout_test.go delete mode 100644 extractor/common.go delete mode 100644 extractor/ecosystems.go delete mode 100644 extractor/extractor.go delete mode 100644 extractor/npm.go delete mode 100644 extractor/pypi.go delete mode 100644 guard/guard.go delete mode 100644 guard/guard_test.go delete mode 100644 internal/flows/common_flow.go delete mode 100644 packagemanager/dependency_resolver.go delete mode 100644 packagemanager/noop_resolver.go delete mode 100644 packagemanager/npm_resolver.go delete mode 100644 packagemanager/npm_resolver_test.go diff --git a/.github/workflows/pmg-e2e.yml b/.github/workflows/pmg-e2e.yml index 357e247..77abcc1 100644 --- a/.github/workflows/pmg-e2e.yml +++ b/.github/workflows/pmg-e2e.yml @@ -91,9 +91,9 @@ jobs: run: | echo "Testing NPM single package installation..." mkdir npm-test && cd npm-test - pmg --proxy-mode=false npm init -y - pmg --proxy-mode=false npm install express@5.2.1 - pmg --proxy-mode=false npm install lodash@4.17.21 + pmg npm init -y + pmg npm install express@5.2.1 + pmg npm install lodash@4.17.21 # Verification: npm added packages present and manifest updated test -d node_modules/express @@ -103,7 +103,7 @@ jobs: echo "Testing NPM manifest installation..." rm -rf node_modules package-lock.json - pmg --proxy-mode=false npm install + pmg npm install # Verification: npm lockfile and installed modules exist after manifest install test -f package-lock.json @@ -293,8 +293,8 @@ jobs: # onFail:download, and the following `pnpm add` then crashes with # "Cannot use 'in' operator to search for 'integrity' in undefined". npm init -y - pmg --proxy-mode=false pnpm add express@5.2.1 - pmg --proxy-mode=false pnpm add lodash@4.17.21 + pmg pnpm add express@5.2.1 + pmg pnpm add lodash@4.17.21 # Verification: pnpm packages installed and lockfile created test -d node_modules/express @@ -303,7 +303,7 @@ jobs: echo "Testing PNPM manifest installation..." rm -rf node_modules pnpm-lock.yaml - pmg --proxy-mode=false pnpm install + pmg pnpm install # Verification: pnpm lockfile and modules exist after manifest install test -f pnpm-lock.yaml @@ -315,9 +315,9 @@ jobs: run: | echo "Testing Bun single package installation..." mkdir bun-test && cd bun-test - pmg --proxy-mode=false bun init -y - pmg --proxy-mode=false bun add express@5.2.1 - pmg --proxy-mode=false bun add lodash@4.17.21 + pmg bun init -y + pmg bun add express@5.2.1 + pmg bun add lodash@4.17.21 # Verification: bun packages installed and lockfile created test -d node_modules/express @@ -326,7 +326,7 @@ jobs: echo "Testing Bun manifest installation..." rm -rf node_modules bun.lock - pmg --proxy-mode=false bun install + pmg bun install # Verification: bun lockfile and modules exist after manifest install test -f bun.lock @@ -335,7 +335,7 @@ jobs: echo "Testing Bun frozen manifest installation with bun ci..." rm -rf node_modules - pmg --proxy-mode=false bun ci + pmg bun ci # Verification: bun lockfile and modules exist after frozen manifest install test -f bun.lock @@ -392,9 +392,9 @@ jobs: yarn --version YARN_TESTDIR=$(mktemp -d) && cd "$YARN_TESTDIR" - pmg --proxy-mode=false yarn init -y - pmg --proxy-mode=false yarn add express@5.2.1 - pmg --proxy-mode=false yarn add lodash@4.17.21 + pmg yarn init -y + pmg yarn add express@5.2.1 + pmg yarn add lodash@4.17.21 # Verification: yarn packages installed and lockfile created test -d node_modules/express @@ -403,7 +403,7 @@ jobs: echo "Testing Yarn manifest installation..." rm -rf node_modules yarn.lock - pmg --proxy-mode=false yarn install + pmg yarn install # Verification: yarn lockfile and modules exist after manifest install test -f yarn.lock @@ -417,19 +417,19 @@ jobs: mkdir npx-test && cd npx-test echo "Testing npx with a simple package..." - pmg --proxy-mode=false npx cowsay@1.6.0 "Hello from pmg npx" | tee npx-output.txt + pmg npx cowsay@1.6.0 "Hello from pmg npx" | tee npx-output.txt # Verification: cowsay output contains our message grep -q "Hello from pmg npx" npx-output.txt echo "Testing npx with --package flag..." - pmg --proxy-mode=false npx --package cowsay@1.6.0 -- cowsay "Hello with package flag" | tee npx-pkg-output.txt + pmg npx --package cowsay@1.6.0 -- cowsay "Hello with package flag" | tee npx-pkg-output.txt # Verification: package flag execution produces expected output grep -q "Hello with package flag" npx-pkg-output.txt echo "Testing npx dry-run mode..." - pmg --proxy-mode=false --dry-run npx cowsay@1.6.0 "This should not execute" | tee npx-dry-output.txt + pmg --dry-run npx cowsay@1.6.0 "This should not execute" | tee npx-dry-output.txt # Verification: dry-run should NOT produce cowsay ASCII art (cow face ^__^ should not appear) ! grep -q '\^__\^' npx-dry-output.txt @@ -442,19 +442,19 @@ jobs: PNPX_TESTDIR=$(mktemp -d) && cd "$PNPX_TESTDIR" echo "Testing pnpx with a simple package..." - pmg --proxy-mode=false pnpx cowsay@1.6.0 "Hello from pmg pnpx" | tee pnpx-output.txt + pmg pnpx cowsay@1.6.0 "Hello from pmg pnpx" | tee pnpx-output.txt # Verification: cowsay output contains our message grep -q "Hello from pmg pnpx" pnpx-output.txt echo "Testing pnpx with --package flag..." - pmg --proxy-mode=false pnpx --package cowsay@1.6.0 -- cowsay "Hello with package flag" | tee pnpx-pkg-output.txt + pmg pnpx --package cowsay@1.6.0 -- cowsay "Hello with package flag" | tee pnpx-pkg-output.txt # Verification: package flag execution produces expected output grep -q "Hello with package flag" pnpx-pkg-output.txt echo "Testing pnpx dry-run mode..." - pmg --proxy-mode=false --dry-run pnpx cowsay@1.6.0 "This should not execute" | tee pnpx-dry-output.txt + pmg --dry-run pnpx cowsay@1.6.0 "This should not execute" | tee pnpx-dry-output.txt # Verification: dry-run should NOT produce cowsay ASCII art (cow face ^__^ should not appear) ! grep -q '\^__\^' pnpx-dry-output.txt @@ -471,18 +471,18 @@ jobs: # Verification: pinned version is resolved and executed via the proxy grep -q "0.6.9" uvx-output.txt - echo "Testing uvx version pin via @ syntax (non-proxy)..." - pmg --proxy-mode=false uvx ruff@0.6.9 --version | tee uvx-pin-output.txt + echo "Testing uvx version pin via @ syntax..." + pmg uvx ruff@0.6.9 --version | tee uvx-pin-output.txt grep -q "0.6.9" uvx-pin-output.txt echo "Testing uvx with --from (command name differs from package)..." - pmg --proxy-mode=false uvx --from cowsay cowsay -t "Hello from pmg uvx" | tee uvx-from-output.txt + pmg uvx --from cowsay cowsay -t "Hello from pmg uvx" | tee uvx-from-output.txt # Verification: cowsay output contains our message and cow art grep -q "Hello from pmg uvx" uvx-from-output.txt grep -q '\^__\^' uvx-from-output.txt echo "Testing uvx dry-run mode..." - pmg --proxy-mode=false --dry-run uvx --from cowsay cowsay -t "This should not execute" | tee uvx-dry-output.txt + pmg --dry-run uvx --from cowsay cowsay -t "This should not execute" | tee uvx-dry-output.txt # Verification: dry-run should NOT produce cowsay ASCII art (cow face ^__^) ! grep -q '\^__\^' uvx-dry-output.txt @@ -493,9 +493,9 @@ jobs: echo "Testing Pip single package installation..." mkdir pip-test && cd pip-test python -m venv venv && source venv/bin/activate - pmg --proxy-mode=false pip install requests==2.32.4 - pmg --proxy-mode=false pip install numpy==2.3.5 - pmg --proxy-mode=false pip freeze > requirements.txt + pmg pip install requests==2.32.4 + pmg pip install numpy==2.3.5 + pmg pip freeze > requirements.txt # Verification: requirements.txt contains expected packages test -s requirements.txt @@ -503,8 +503,8 @@ jobs: grep -E '^numpy==' requirements.txt echo "Testing Pip manifest installation..." - pmg --proxy-mode=false pip uninstall -y requests numpy - pmg --proxy-mode=false pip install -r requirements.txt + pmg pip uninstall -y requests numpy + pmg pip install -r requirements.txt # Verification: imported packages are available in the environment python -c "import requests, numpy; print(requests.__version__); print(numpy.__version__)" @@ -516,9 +516,9 @@ jobs: echo "Testing Pip3 single package installation..." mkdir pip3-test && cd pip3-test python -m venv venv && source venv/bin/activate - pmg --proxy-mode=false pip3 install requests==2.32.4 - pmg --proxy-mode=false pip3 install numpy==2.3.5 - pmg --proxy-mode=false pip3 freeze > requirements.txt + pmg pip3 install requests==2.32.4 + pmg pip3 install numpy==2.3.5 + pmg pip3 freeze > requirements.txt # Verification: requirements.txt contains expected packages test -s requirements.txt @@ -526,8 +526,8 @@ jobs: grep -E '^numpy==' requirements.txt echo "Testing Pip3 manifest installation..." - pmg --proxy-mode=false pip3 uninstall -y requests numpy - pmg --proxy-mode=false pip3 install -r requirements.txt + pmg pip3 uninstall -y requests numpy + pmg pip3 install -r requirements.txt # Verification: imported packages are available in the environment python -c "import requests, numpy; print(requests.__version__); print(numpy.__version__)" @@ -538,9 +538,9 @@ jobs: run: | echo "Testing UV single package installation..." mkdir uv-test && cd uv-test - pmg --proxy-mode=false uv init --no-readme - pmg --proxy-mode=false uv add requests==2.32.4 - pmg --proxy-mode=false uv add numpy==2.3.5 + pmg uv init --no-readme + pmg uv add requests==2.32.4 + pmg uv add numpy==2.3.5 # Verification: pyproject.toml lists expected dependencies test -f pyproject.toml @@ -549,31 +549,31 @@ jobs: echo "Testing UV manifest installation..." rm -rf .venv uv.lock - pmg --proxy-mode=false uv sync + pmg uv sync # Verification: uv lockfile and virtualenv created; packages present test -d .venv test -f uv.lock - pmg --proxy-mode=false uv pip show requests >/dev/null - pmg --proxy-mode=false uv pip show numpy >/dev/null + pmg uv pip show requests >/dev/null + pmg uv pip show numpy >/dev/null echo "Testing UV pip commands..." - pmg --proxy-mode=false uv pip freeze > requirements.txt - pmg --proxy-mode=false uv pip install -r requirements.txt - pmg --proxy-mode=false uv pip sync requirements.txt + pmg uv pip freeze > requirements.txt + pmg uv pip install -r requirements.txt + pmg uv pip sync requirements.txt # Verification: uv pip can show installed packages after requirements sync - pmg --proxy-mode=false uv pip show requests >/dev/null - pmg --proxy-mode=false uv pip show numpy >/dev/null + pmg uv pip show requests >/dev/null + pmg uv pip show numpy >/dev/null cd .. && rm -rf uv-test - name: Test Poetry - Single Package & Manifest run: | echo "Testing Poetry single package installation..." mkdir poetry-test && cd poetry-test - pmg --proxy-mode=false poetry init --name poetry-test --no-interaction --quiet - pmg --proxy-mode=false poetry add requests==2.32.4 - pmg --proxy-mode=false poetry add numpy==2.3.5 + pmg poetry init --name poetry-test --no-interaction --quiet + pmg poetry add requests==2.32.4 + pmg poetry add numpy==2.3.5 # Verification: pyproject.toml dependencies updated test -f pyproject.toml @@ -582,15 +582,19 @@ jobs: echo "Testing Poetry manifest installation..." rm -rf .venv poetry.lock - pmg --proxy-mode=false poetry install --no-root + pmg poetry install --no-root cd .. && rm -rf poetry-test - name: Test Malicious Package Detection run: | echo "Testing malicious package detection..." mkdir malicious-test && cd malicious-test - pmg --proxy-mode=false npm init -y - ! pmg --proxy-mode=false npm install nyc-config@10.0.0 || echo "Malicious package correctly blocked" + pmg npm init -y + if pmg npm install nyc-config@10.0.0; then + echo "ERROR: malicious package was not blocked!" + exit 1 + fi + echo "Malicious package correctly blocked" cd .. && rm -rf malicious-test - name: Test safedep-test-pkg is Blocked using Proxy mode @@ -620,29 +624,47 @@ jobs: mkdir pmg-modes-test && cd pmg-modes-test pmg npm init -y # Mode: --dry-run should not create node_modules or lockfiles - pmg --proxy-mode=false --dry-run npm install express + pmg --dry-run npm install express # Verification: no files created during dry-run test ! -d node_modules test ! -f package-lock.json # Mode: --silent should install without noisy output - pmg --proxy-mode=false --silent npm install express + pmg --silent npm install express # Verification: package installed test -d node_modules/express # Clean and test --verbose installation rm -rf node_modules package-lock.json - pmg --proxy-mode=false --verbose npm install express + pmg --verbose npm install express # Verification: package installed test -d node_modules/express # Clean and test --debug with log output rm -rf node_modules package-lock.json - pmg --proxy-mode=false --debug --log debug.json npm install express + pmg --debug --log debug.json npm install express # Verification: debug log written test -f debug.json # Mode: --paranoid may require cloud credentials; run non-blocking with dry-run - pmg --proxy-mode=false --paranoid --dry-run npm install express || true + pmg --paranoid --dry-run npm install express || true + + # Removed proxy opt-outs must fail loudly, not silently proxy + if PMG_PROXY_ENABLED=false pmg --dry-run npm install express; then + echo "ERROR: PMG_PROXY_ENABLED=false should fail loudly" + exit 1 + fi + # Legacy PMG_PROXY_MODE only selected guard mode when the config has + # no proxy: section; with the standard (template) config it was inert + # and must keep working. + PMG_PROXY_MODE=false pmg --dry-run npm install express + if PMG_CONFIG_DIR=$(mktemp -d) PMG_PROXY_MODE=false pmg --dry-run npm install express; then + echo "ERROR: PMG_PROXY_MODE=false without a proxy: section should fail loudly" + exit 1 + fi + if pmg --proxy-mode=false --dry-run npm install express; then + echo "ERROR: --proxy-mode should be an unknown flag" + exit 1 + fi cd .. && rm -rf pmg-modes-test sandbox-e2e-macos: diff --git a/action.yml b/action.yml index 538d1b9..b297f68 100644 --- a/action.yml +++ b/action.yml @@ -37,7 +37,7 @@ inputs: required: false default: "" proxy-mode: - description: Use proxy-based interception (PMG_PROXY_ENABLED). Empty = use PMG default (true). Setting "false" falls back to guard-based analysis. + description: REMOVED. PMG always uses proxy-based interception. Setting "false" fails the action; any other value is ignored with a warning. required: false default: "" @@ -252,6 +252,19 @@ runs: fi } + # Guard mode is removed: fail loudly when a workflow still opts out of + # proxy interception instead of silently switching it to proxy mode. + case "$(echo "$IN_PROXY_MODE" | tr '[:upper:]' '[:lower:]')" in + false|0|f) + echo "::error::PMG guard mode has been removed and proxy interception can no longer be disabled. Remove the 'proxy-mode' input." >&2 + exit 1 + ;; + "") ;; + *) + echo "::warning::The 'proxy-mode' input is removed and has no effect. Remove it from your workflow." >&2 + ;; + esac + # Cloud credentials. PMG sync reads these directly when no keychain # credential is found, so we skip "pmg cloud login" entirely. if [ -n "$IN_API_KEY" ] || [ -n "$IN_TENANT_ID" ]; then @@ -294,7 +307,6 @@ runs: export_var PMG_PARANOID "$IN_PARANOID" export_var PMG_DEPENDENCY_COOLDOWN_ENABLED "$IN_COOLDOWN_ENABLED" export_var PMG_DEPENDENCY_COOLDOWN_DAYS "$IN_COOLDOWN_DAYS" - export_var PMG_PROXY_ENABLED "$IN_PROXY_MODE" export_var PMG_SANDBOX_ENABLED "$IN_SANDBOX" if [ "$IN_SANDBOX" = "true" ]; then driver="${IN_SANDBOX_DRIVER:-landlock}" diff --git a/cmd/executors/npx.go b/cmd/executors/npx.go index b088220..d53984a 100644 --- a/cmd/executors/npx.go +++ b/cmd/executors/npx.go @@ -4,7 +4,6 @@ import ( "context" "fmt" - "github.com/safedep/pmg/config" "github.com/safedep/pmg/internal/analytics" "github.com/safedep/pmg/internal/flows" "github.com/safedep/pmg/internal/ui" @@ -35,25 +34,5 @@ func executeNpxFlow(ctx context.Context, args []string) error { return fmt.Errorf("failed to create npx package executor proxy: %w", err) } - config := config.Get() - parsedCommand, err := packageExecutor.ParseCommand(args) - if err != nil { - return fmt.Errorf("failed to parse command: %w", err) - } - - packageResolverConfig := packagemanager.NewDefaultNpmDependencyResolverConfig() - packageResolverConfig.IncludeTransitiveDependencies = config.Config.Transitive - packageResolverConfig.TransitiveDepth = config.Config.TransitiveDepth - packageResolverConfig.IncludeDevDependencies = config.Config.IncludeDevDependencies - - packageResolver, err := packagemanager.NewNpmDependencyResolver(packageResolverConfig) - if err != nil { - return fmt.Errorf("failed to create dependency resolver: %w", err) - } - - if !config.IsProxyModeEnabled() { - return flows.Common(packageExecutor, packageResolver).Run(ctx, args, parsedCommand) - } - - return flows.ProxyFlow(packageExecutor, packageResolver).Run(ctx, args, parsedCommand) + return flows.RunProxy(ctx, packageExecutor, args) } diff --git a/cmd/executors/pipx.go b/cmd/executors/pipx.go index 1d729e2..1447941 100644 --- a/cmd/executors/pipx.go +++ b/cmd/executors/pipx.go @@ -4,7 +4,6 @@ import ( "context" "fmt" - "github.com/safedep/pmg/config" "github.com/safedep/pmg/internal/analytics" "github.com/safedep/pmg/internal/flows" "github.com/safedep/pmg/internal/ui" @@ -36,26 +35,5 @@ func executePipxFlow(ctx context.Context, args []string) error { return fmt.Errorf("failed to create pipx package executor proxy: %w", err) } - config := config.Get() - parsedCommand, err := packageExecutor.ParseCommand(args) - if err != nil { - return fmt.Errorf("failed to parse command: %w", err) - } - - packageResolverConfig := packagemanager.NewDefaultPypiDependencyResolverConfig() - packageResolverConfig.IncludeTransitiveDependencies = config.Config.Transitive - packageResolverConfig.TransitiveDepth = config.Config.TransitiveDepth - packageResolverConfig.IncludeDevDependencies = config.Config.IncludeDevDependencies - packageResolverConfig.PackageInstallTargets = parsedCommand.InstallTargets - - packageResolver, err := packagemanager.NewPypiDependencyResolver(packageResolverConfig) - if err != nil { - return fmt.Errorf("failed to create dependency resolver: %w", err) - } - - if !config.IsProxyModeEnabled() { - return flows.Common(packageExecutor, packageResolver).Run(ctx, args, parsedCommand) - } - - return flows.ProxyFlow(packageExecutor, packageResolver).Run(ctx, args, parsedCommand) + return flows.RunProxy(ctx, packageExecutor, args) } diff --git a/cmd/executors/pnpx.go b/cmd/executors/pnpx.go index 08d4a1c..771f725 100644 --- a/cmd/executors/pnpx.go +++ b/cmd/executors/pnpx.go @@ -4,7 +4,6 @@ import ( "context" "fmt" - "github.com/safedep/pmg/config" "github.com/safedep/pmg/internal/analytics" "github.com/safedep/pmg/internal/flows" "github.com/safedep/pmg/internal/ui" @@ -35,25 +34,5 @@ func executePnpxFlow(ctx context.Context, args []string) error { return fmt.Errorf("failed to create pnpx package executor proxy: %w", err) } - config := config.Get() - parsedCommand, err := packageExecutor.ParseCommand(args) - if err != nil { - return fmt.Errorf("failed to parse command: %w", err) - } - - packageResolverConfig := packagemanager.NewDefaultNpmDependencyResolverConfig() - packageResolverConfig.IncludeTransitiveDependencies = config.Config.Transitive - packageResolverConfig.TransitiveDepth = config.Config.TransitiveDepth - packageResolverConfig.IncludeDevDependencies = config.Config.IncludeDevDependencies - - packageResolver, err := packagemanager.NewNpmDependencyResolver(packageResolverConfig) - if err != nil { - return fmt.Errorf("failed to create dependency resolver: %w", err) - } - - if !config.IsProxyModeEnabled() { - return flows.Common(packageExecutor, packageResolver).Run(ctx, args, parsedCommand) - } - - return flows.ProxyFlow(packageExecutor, packageResolver).Run(ctx, args, parsedCommand) + return flows.RunProxy(ctx, packageExecutor, args) } diff --git a/cmd/executors/uvx.go b/cmd/executors/uvx.go index e2138fe..a6fecec 100644 --- a/cmd/executors/uvx.go +++ b/cmd/executors/uvx.go @@ -4,7 +4,6 @@ import ( "context" "fmt" - "github.com/safedep/pmg/config" "github.com/safedep/pmg/internal/analytics" "github.com/safedep/pmg/internal/flows" "github.com/safedep/pmg/internal/ui" @@ -36,26 +35,5 @@ func executeUvxFlow(ctx context.Context, args []string) error { return fmt.Errorf("failed to create uvx package executor proxy: %w", err) } - config := config.Get() - parsedCommand, err := packageExecutor.ParseCommand(args) - if err != nil { - return fmt.Errorf("failed to parse command: %w", err) - } - - packageResolverConfig := packagemanager.NewDefaultPypiDependencyResolverConfig() - packageResolverConfig.IncludeTransitiveDependencies = config.Config.Transitive - packageResolverConfig.TransitiveDepth = config.Config.TransitiveDepth - packageResolverConfig.IncludeDevDependencies = config.Config.IncludeDevDependencies - packageResolverConfig.PackageInstallTargets = parsedCommand.InstallTargets - - packageResolver, err := packagemanager.NewPypiDependencyResolver(packageResolverConfig) - if err != nil { - return fmt.Errorf("failed to create dependency resolver: %w", err) - } - - if !config.IsProxyModeEnabled() { - return flows.Common(packageExecutor, packageResolver).Run(ctx, args, parsedCommand) - } - - return flows.ProxyFlow(packageExecutor, packageResolver).Run(ctx, args, parsedCommand) + return flows.RunProxy(ctx, packageExecutor, args) } diff --git a/cmd/golang/go.go b/cmd/golang/go.go index 52582f5..f358f88 100644 --- a/cmd/golang/go.go +++ b/cmd/golang/go.go @@ -43,20 +43,18 @@ func executeGoFlow(ctx context.Context, args []string) error { return fmt.Errorf("failed to create go package manager: %w", err) } - parsedCommand, err := packageManager.ParseCommand(args) - if err != nil { - return fmt.Errorf("failed to parse command: %w", err) - } - - if !config.Get().IsProxyModeEnabled() { - return errGoRequiresProxyMode() + // Reject a removed proxy opt-out before the CA trust check: the old code + // reported the mode error first, and a config problem must not steer the + // user into an unnecessary OS trust store change. + if err := config.RejectRemovedProxyOptOut(); err != nil { + return err } if err := requireTrustedCA(); err != nil { return err } - return flows.ProxyFlow(packageManager, packagemanager.NewNoopPackageResolver()).Run(ctx, args, parsedCommand) + return flows.RunProxy(ctx, packageManager, args) } // requireTrustedCA fails fast when Go cannot trust PMG's MITM CA. Go's @@ -85,14 +83,6 @@ func requireTrustedCA() error { return nil } -func errGoRequiresProxyMode() error { - return usefulerror.NewUsefulError(). - WithCode(errcodes.InvalidArgument). - WithHumanError("Go support requires proxy mode, which is disabled in your configuration."). - WithHelp("Enable proxy mode (proxy.enabled: true in the PMG config) and retry."). - WithMsg("go requires proxy mode") -} - func errGoCertNotTrusted(cause error) error { err := usefulerror.NewUsefulError(). WithCode(errcodes.CertTrustStore). diff --git a/cmd/npm/bun.go b/cmd/npm/bun.go index f909849..b338c9e 100644 --- a/cmd/npm/bun.go +++ b/cmd/npm/bun.go @@ -4,7 +4,6 @@ import ( "context" "fmt" - "github.com/safedep/pmg/config" "github.com/safedep/pmg/internal/analytics" "github.com/safedep/pmg/internal/flows" "github.com/safedep/pmg/internal/ui" @@ -35,25 +34,5 @@ func executeBunFlow(ctx context.Context, args []string) error { return fmt.Errorf("failed to create bun package manager proxy: %w", err) } - config := config.Get() - parsedCommand, err := packageManager.ParseCommand(args) - if err != nil { - return fmt.Errorf("failed to parse command: %w", err) - } - - packageResolverConfig := packagemanager.NewDefaultNpmDependencyResolverConfig() - packageResolverConfig.IncludeTransitiveDependencies = config.Config.Transitive - packageResolverConfig.TransitiveDepth = config.Config.TransitiveDepth - packageResolverConfig.IncludeDevDependencies = config.Config.IncludeDevDependencies - - packageResolver, err := packagemanager.NewNpmDependencyResolver(packageResolverConfig) - if err != nil { - return fmt.Errorf("failed to create dependency resolver: %w", err) - } - - if !config.IsProxyModeEnabled() { - return flows.Common(packageManager, packageResolver).Run(ctx, args, parsedCommand) - } - - return flows.ProxyFlow(packageManager, packageResolver).Run(ctx, args, parsedCommand) + return flows.RunProxy(ctx, packageManager, args) } diff --git a/cmd/npm/npm.go b/cmd/npm/npm.go index abdd71b..ab5a40c 100644 --- a/cmd/npm/npm.go +++ b/cmd/npm/npm.go @@ -4,7 +4,6 @@ import ( "context" "fmt" - "github.com/safedep/pmg/config" "github.com/safedep/pmg/internal/analytics" "github.com/safedep/pmg/internal/flows" "github.com/safedep/pmg/internal/ui" @@ -35,25 +34,5 @@ func executeNpmFlow(ctx context.Context, args []string) error { return fmt.Errorf("failed to create npm package manager proxy: %w", err) } - config := config.Get() - parsedCommand, err := packageManager.ParseCommand(args) - if err != nil { - return fmt.Errorf("failed to parse command: %w", err) - } - - packageResolverConfig := packagemanager.NewDefaultNpmDependencyResolverConfig() - packageResolverConfig.IncludeTransitiveDependencies = config.Config.Transitive - packageResolverConfig.TransitiveDepth = config.Config.TransitiveDepth - packageResolverConfig.IncludeDevDependencies = config.Config.IncludeDevDependencies - - packageResolver, err := packagemanager.NewNpmDependencyResolver(packageResolverConfig) - if err != nil { - return fmt.Errorf("failed to create dependency resolver: %w", err) - } - - if !config.IsProxyModeEnabled() { - return flows.Common(packageManager, packageResolver).Run(ctx, args, parsedCommand) - } - - return flows.ProxyFlow(packageManager, packageResolver).Run(ctx, args, parsedCommand) + return flows.RunProxy(ctx, packageManager, args) } diff --git a/cmd/npm/pnpm.go b/cmd/npm/pnpm.go index fda9a36..7d957a6 100644 --- a/cmd/npm/pnpm.go +++ b/cmd/npm/pnpm.go @@ -4,7 +4,6 @@ import ( "context" "fmt" - "github.com/safedep/pmg/config" "github.com/safedep/pmg/internal/analytics" "github.com/safedep/pmg/internal/flows" "github.com/safedep/pmg/internal/ui" @@ -35,26 +34,5 @@ func executePnpmFlow(ctx context.Context, args []string) error { return fmt.Errorf("failed to create pnpm package manager proxy: %w", err) } - config := config.Get() - - parsedCommand, err := packageManager.ParseCommand(args) - if err != nil { - return fmt.Errorf("failed to parse command: %w", err) - } - - packageResolverConfig := packagemanager.NewDefaultNpmDependencyResolverConfig() - packageResolverConfig.IncludeTransitiveDependencies = config.Config.Transitive - packageResolverConfig.TransitiveDepth = config.Config.TransitiveDepth - packageResolverConfig.IncludeDevDependencies = config.Config.IncludeDevDependencies - - packageResolver, err := packagemanager.NewNpmDependencyResolver(packageResolverConfig) - if err != nil { - return fmt.Errorf("failed to create dependency resolver: %w", err) - } - - if !config.IsProxyModeEnabled() { - return flows.Common(packageManager, packageResolver).Run(ctx, args, parsedCommand) - } - - return flows.ProxyFlow(packageManager, packageResolver).Run(ctx, args, parsedCommand) + return flows.RunProxy(ctx, packageManager, args) } diff --git a/cmd/npm/yarn.go b/cmd/npm/yarn.go index 7072983..de78f4f 100644 --- a/cmd/npm/yarn.go +++ b/cmd/npm/yarn.go @@ -4,7 +4,6 @@ import ( "context" "fmt" - "github.com/safedep/pmg/config" "github.com/safedep/pmg/internal/analytics" "github.com/safedep/pmg/internal/flows" "github.com/safedep/pmg/internal/ui" @@ -35,25 +34,5 @@ func executeYarnFlow(ctx context.Context, args []string) error { return fmt.Errorf("failed to create yarn package manager proxy: %w", err) } - config := config.Get() - parsedCommand, err := packageManager.ParseCommand(args) - if err != nil { - return fmt.Errorf("failed to parse command: %w", err) - } - - packageResolverConfig := packagemanager.NewDefaultNpmDependencyResolverConfig() - packageResolverConfig.IncludeTransitiveDependencies = config.Config.Transitive - packageResolverConfig.TransitiveDepth = config.Config.TransitiveDepth - packageResolverConfig.IncludeDevDependencies = config.Config.IncludeDevDependencies - - packageResolver, err := packagemanager.NewNpmDependencyResolver(packageResolverConfig) - if err != nil { - return fmt.Errorf("failed to create dependency resolver: %w", err) - } - - if !config.IsProxyModeEnabled() { - return flows.Common(packageManager, packageResolver).Run(ctx, args, parsedCommand) - } - - return flows.ProxyFlow(packageManager, packageResolver).Run(ctx, args, parsedCommand) + return flows.RunProxy(ctx, packageManager, args) } diff --git a/cmd/pypi/pip.go b/cmd/pypi/pip.go index 73b1346..91b0836 100644 --- a/cmd/pypi/pip.go +++ b/cmd/pypi/pip.go @@ -4,7 +4,6 @@ import ( "context" "fmt" - "github.com/safedep/pmg/config" "github.com/safedep/pmg/internal/analytics" "github.com/safedep/pmg/internal/flows" "github.com/safedep/pmg/internal/ui" @@ -35,27 +34,5 @@ func executePipFlow(ctx context.Context, args []string) error { return fmt.Errorf("failed to create pip package manager proxy: %w", err) } - config := config.Get() - parsedCommand, err := packageManager.ParseCommand(args) - if err != nil { - return fmt.Errorf("failed to parse command: %w", err) - } - - // Parse the args right here - packageResolverConfig := packagemanager.NewDefaultPypiDependencyResolverConfig() - packageResolverConfig.IncludeTransitiveDependencies = config.Config.Transitive - packageResolverConfig.TransitiveDepth = config.Config.TransitiveDepth - packageResolverConfig.IncludeDevDependencies = config.Config.IncludeDevDependencies - packageResolverConfig.PackageInstallTargets = parsedCommand.InstallTargets - - packageResolver, err := packagemanager.NewPypiDependencyResolver(packageResolverConfig) - if err != nil { - return fmt.Errorf("failed to create dependency resolver: %w", err) - } - - if config.IsProxyModeEnabled() { - return flows.ProxyFlow(packageManager, packageResolver).Run(ctx, args, parsedCommand) - } - - return flows.Common(packageManager, packageResolver).Run(ctx, args, parsedCommand) + return flows.RunProxy(ctx, packageManager, args) } diff --git a/cmd/pypi/pip3.go b/cmd/pypi/pip3.go index 3e35af6..10fdbff 100644 --- a/cmd/pypi/pip3.go +++ b/cmd/pypi/pip3.go @@ -4,7 +4,6 @@ import ( "context" "fmt" - "github.com/safedep/pmg/config" "github.com/safedep/pmg/internal/analytics" "github.com/safedep/pmg/internal/flows" "github.com/safedep/pmg/internal/ui" @@ -35,27 +34,5 @@ func executePip3Flow(ctx context.Context, args []string) error { return fmt.Errorf("failed to create pip3 package manager proxy: %w", err) } - config := config.Get() - parsedCommand, err := packageManager.ParseCommand(args) - if err != nil { - return fmt.Errorf("failed to parse command: %w", err) - } - - // Parse the args right here - packageResolverConfig := packagemanager.NewDefaultPypiDependencyResolverConfig() - packageResolverConfig.IncludeTransitiveDependencies = config.Config.Transitive - packageResolverConfig.TransitiveDepth = config.Config.TransitiveDepth - packageResolverConfig.IncludeDevDependencies = config.Config.IncludeDevDependencies - packageResolverConfig.PackageInstallTargets = parsedCommand.InstallTargets - - packageResolver, err := packagemanager.NewPypiDependencyResolver(packageResolverConfig) - if err != nil { - return fmt.Errorf("failed to create dependency resolver: %w", err) - } - - if config.IsProxyModeEnabled() { - return flows.ProxyFlow(packageManager, packageResolver).Run(ctx, args, parsedCommand) - } - - return flows.Common(packageManager, packageResolver).Run(ctx, args, parsedCommand) + return flows.RunProxy(ctx, packageManager, args) } diff --git a/cmd/pypi/poetry.go b/cmd/pypi/poetry.go index b8878dd..9dbfcd5 100644 --- a/cmd/pypi/poetry.go +++ b/cmd/pypi/poetry.go @@ -4,7 +4,6 @@ import ( "context" "fmt" - "github.com/safedep/pmg/config" "github.com/safedep/pmg/internal/analytics" "github.com/safedep/pmg/internal/flows" "github.com/safedep/pmg/internal/ui" @@ -35,26 +34,5 @@ func executePoetryFlow(ctx context.Context, args []string) error { return fmt.Errorf("failed to create poetry package manager: %w", err) } - config := config.Get() - parsedCommand, err := packageManager.ParseCommand(args) - if err != nil { - return fmt.Errorf("failed to parse command: %w", err) - } - - packageResolverConfig := packagemanager.NewDefaultPypiDependencyResolverConfig() - packageResolverConfig.IncludeTransitiveDependencies = config.Config.Transitive - packageResolverConfig.TransitiveDepth = config.Config.TransitiveDepth - packageResolverConfig.IncludeDevDependencies = config.Config.IncludeDevDependencies - packageResolverConfig.PackageInstallTargets = parsedCommand.InstallTargets - - packageResolver, err := packagemanager.NewPypiDependencyResolver(packageResolverConfig) - if err != nil { - return fmt.Errorf("failed to create dependency resolver: %w", err) - } - - if config.IsProxyModeEnabled() { - return flows.ProxyFlow(packageManager, packageResolver).Run(ctx, args, parsedCommand) - } - - return flows.Common(packageManager, packageResolver).Run(ctx, args, parsedCommand) + return flows.RunProxy(ctx, packageManager, args) } diff --git a/cmd/pypi/uv.go b/cmd/pypi/uv.go index e3b1f89..f89631f 100644 --- a/cmd/pypi/uv.go +++ b/cmd/pypi/uv.go @@ -4,7 +4,6 @@ import ( "context" "fmt" - "github.com/safedep/pmg/config" "github.com/safedep/pmg/internal/analytics" "github.com/safedep/pmg/internal/flows" "github.com/safedep/pmg/internal/ui" @@ -35,26 +34,5 @@ func executeUvFlow(ctx context.Context, args []string) error { return fmt.Errorf("failed to create uv package manager: %w", err) } - config := config.Get() - parsedCommand, err := packageManager.ParseCommand(args) - if err != nil { - return fmt.Errorf("failed to parse command: %w", err) - } - - packageResolverConfig := packagemanager.NewDefaultPypiDependencyResolverConfig() - packageResolverConfig.IncludeTransitiveDependencies = config.Config.Transitive - packageResolverConfig.TransitiveDepth = config.Config.TransitiveDepth - packageResolverConfig.IncludeDevDependencies = config.Config.IncludeDevDependencies - packageResolverConfig.PackageInstallTargets = parsedCommand.InstallTargets - - packageResolver, err := packagemanager.NewPypiDependencyResolver(packageResolverConfig) - if err != nil { - return fmt.Errorf("failed to create dependency resolver: %w", err) - } - - if config.IsProxyModeEnabled() { - return flows.ProxyFlow(packageManager, packageResolver).Run(ctx, args, parsedCommand) - } - - return flows.Common(packageManager, packageResolver).Run(ctx, args, parsedCommand) + return flows.RunProxy(ctx, packageManager, args) } diff --git a/cmd/setup/doctor.go b/cmd/setup/doctor.go index f1ce573..f5825b2 100644 --- a/cmd/setup/doctor.go +++ b/cmd/setup/doctor.go @@ -27,7 +27,6 @@ const ( checkShellAliases = "shell-aliases" checkShimDirectory = "shim-directory" checkShimInPath = "shim-in-path" - checkProxyMode = "proxy-mode" checkDependencyCooldown = "dependency-cooldown" checkEventLogging = "event-logging" checkSandbox = "sandbox" @@ -175,22 +174,6 @@ func runCoreChecks(cfg *config.RuntimeConfig) []doctor.CheckResult { Category: "Shell Integration", Run: checkShimInPathResult, }, - { - Name: checkProxyMode, - Category: "Security", - Run: func() doctor.CheckResult { - if cfg.IsProxyModeEnabled() { - return doctor.CheckResult{ - Status: doctor.StatusPass, - Message: "Proxy mode is enabled", - } - } - return doctor.CheckResult{ - Status: doctor.StatusFail, - Message: "Proxy mode is disabled", - } - }, - }, { Name: checkDependencyCooldown, Category: "Security", @@ -499,7 +482,6 @@ var checkDisplayNames = map[string]string{ checkShellAliases: "Shell aliases", checkShimDirectory: "Shim directory", checkShimInPath: "Shim in PATH", - checkProxyMode: "Proxy mode", checkDependencyCooldown: "Dependency cooldown", checkEventLogging: "Event logging", checkSandbox: "Sandbox", @@ -515,7 +497,6 @@ var checkFixes = map[string]string{ checkShellAliases: "pmg setup install", checkShimDirectory: "pmg setup install", checkShimInPath: "Restart shell or source profile", - checkProxyMode: "Set proxy.enabled: true in config", checkSandbox: "Set sandbox.enabled: true in config", checkDependencyCooldown: "Set dependency_cooldown.enabled: true in config", checkEventLogging: "Set skip_event_logging: false in config", diff --git a/cmd/setup/info.go b/cmd/setup/info.go index bee13df..498e922 100644 --- a/cmd/setup/info.go +++ b/cmd/setup/info.go @@ -54,7 +54,6 @@ func executeSetupInfo() error { } } configEntries["Config Source"] = configSource - configEntries["Proxy Mode"] = strconv.FormatBool(cfg.IsProxyModeEnabled()) configEntries["Proxy Install Only"] = strconv.FormatBool(cfg.Config.Proxy.InstallOnly) ui.PrintInfoSection("Configuration", configEntries) diff --git a/config/cobra.go b/config/cobra.go index cf8e20d..0c944af 100644 --- a/config/cobra.go +++ b/config/cobra.go @@ -30,24 +30,6 @@ type flagSpec struct { } var configFlagSpecs = []flagSpec{ - { - name: "transitive", usage: "Resolve transitive dependencies", managed: true, - bind: func(fs *pflag.FlagSet, name, usage string) { - fs.BoolVar(&globalConfig.Config.Transitive, name, globalConfig.Config.Transitive, usage) - }, - }, - { - name: "transitive-depth", usage: "Maximum depth of transitive dependencies to resolve", managed: true, - bind: func(fs *pflag.FlagSet, name, usage string) { - fs.IntVar(&globalConfig.Config.TransitiveDepth, name, globalConfig.Config.TransitiveDepth, usage) - }, - }, - { - name: "include-dev-dependencies", usage: "Include dev dependencies in the dependency graph (slows down resolution)", managed: true, - bind: func(fs *pflag.FlagSet, name, usage string) { - fs.BoolVar(&globalConfig.Config.IncludeDevDependencies, name, globalConfig.Config.IncludeDevDependencies, usage) - }, - }, { name: "dry-run", usage: "Dry run skips execution of package manager", managed: false, bind: func(fs *pflag.FlagSet, name, usage string) { @@ -66,12 +48,6 @@ var configFlagSpecs = []flagSpec{ fs.BoolVar(&globalConfig.Config.SkipEventLogging, name, globalConfig.Config.SkipEventLogging, usage) }, }, - { - name: "proxy-mode", usage: "Use proxy based interception", managed: true, - bind: func(fs *pflag.FlagSet, name, usage string) { - fs.BoolVar(&globalConfig.Config.Proxy.Enabled, name, globalConfig.Config.Proxy.Enabled, usage) - }, - }, { name: "sandbox", usage: "Enable sandbox mode to isolate package manager processes (EXPERIMENTAL)", managed: true, bind: func(fs *pflag.FlagSet, name, usage string) { diff --git a/config/cobra_test.go b/config/cobra_test.go index bb71738..4e88ee1 100644 --- a/config/cobra_test.go +++ b/config/cobra_test.go @@ -99,7 +99,6 @@ func TestChangedConfigFlagArgs(t *testing.T) { root.AddCommand(child) root.SetArgs([]string{ "--paranoid=false", - "--transitive-depth", "7", "--sandbox-profile", "strict", "--sandbox-allow", "read=/tmp", "--sandbox-allow", "net-connect=registry.npmjs.org:443", @@ -109,7 +108,6 @@ func TestChangedConfigFlagArgs(t *testing.T) { require.NoError(t, root.Execute()) assert.Equal(t, []string{ - "--transitive-depth", "7", "--paranoid=false", "--sandbox-profile", "strict", "--sandbox-allow", "read=/tmp", @@ -143,8 +141,7 @@ func TestConfigFlagSpecsSSOT(t *testing.T) { }) wantManaged := map[string]bool{ - "transitive": true, "transitive-depth": true, "include-dev-dependencies": true, - "paranoid": true, "skip-event-log": true, "proxy-mode": true, + "paranoid": true, "skip-event-log": true, "sandbox": true, "sandbox-enforce": true, "sandbox-profile": true, "sandbox-allow": true, "skip-dependency-cooldown": true, } diff --git a/config/config.go b/config/config.go index 9f92af1..25417ea 100644 --- a/config/config.go +++ b/config/config.go @@ -75,10 +75,6 @@ var templateConfig string // Here we only define the configuration that can be persisted or loaded from a given source and // not those that we believe should not be persisted (eg. insecure installation, etc.) type Config struct { - Transitive bool `mapstructure:"transitive"` - TransitiveDepth int `mapstructure:"transitive_depth"` - IncludeDevDependencies bool `mapstructure:"include_dev_dependencies"` - // Paranoid enables high-security defaults (e.g., treating suspicious behavior as malicious). Paranoid bool `mapstructure:"paranoid"` @@ -98,9 +94,6 @@ type Config struct { // EventLogRetentionDays is the number of days to retain event logs. EventLogRetentionDays int `mapstructure:"event_log_retention_days"` - // Deprecated: Use Proxy.Enabled instead. Kept for backward compatibility with old config files. - ProxyMode bool `mapstructure:"proxy_mode"` - // Deprecated: Use Proxy.InstallOnly instead. Kept for backward compatibility with old config files. ProxyInstallOnly bool `mapstructure:"proxy_install_only"` @@ -172,7 +165,6 @@ type CloudAutoSyncConfig struct { } type ProxyConfig struct { - Enabled bool `mapstructure:"enabled"` InstallOnly bool `mapstructure:"install_only"` SkipCommands map[string][]string `mapstructure:"skip_commands"` Server ProxyServerConfig `mapstructure:"server"` @@ -435,10 +427,6 @@ func (r *RuntimeConfig) LocalDBFileName() string { return pmgDefaultLocalDBFileName } -func (r *RuntimeConfig) IsProxyModeEnabled() bool { - return r.Config.Proxy.Enabled -} - // SandboxAllowType represents the type of a sandbox allow override. type SandboxAllowType string @@ -473,17 +461,13 @@ func DefaultConfig() RuntimeConfig { return RuntimeConfig{ Config: Config{ - Transitive: true, - TransitiveDepth: 5, - IncludeDevDependencies: false, - Paranoid: false, - DisableTelemetry: false, - EventLogRetentionDays: 7, - SkipEventLogging: false, - TrustedPackages: []TrustedPackage{}, - AdvisoryMessage: "", - ProxyMode: true, - Verbosity: VerbosityNormal, + Paranoid: false, + DisableTelemetry: false, + EventLogRetentionDays: 7, + SkipEventLogging: false, + TrustedPackages: []TrustedPackage{}, + AdvisoryMessage: "", + Verbosity: VerbosityNormal, Sandbox: SandboxConfig{ Enabled: false, EnforceAlways: false, @@ -507,7 +491,6 @@ func DefaultConfig() RuntimeConfig { }, }, Proxy: ProxyConfig{ - Enabled: true, InstallOnly: false, SkipCommands: map[string][]string{}, Server: ProxyServerConfig{ diff --git a/config/config.template.yml b/config/config.template.yml index 91120c0..0eb126a 100644 --- a/config/config.template.yml +++ b/config/config.template.yml @@ -1,15 +1,6 @@ # PMG configuration template. Customize this file as needed. # https://github.com/safedep/pmg -# Enable transitive dependency resolution. Default is true. -transitive: true - -# Maximum depth of transitive dependencies to resolve. Default is 5. -transitive_depth: 5 - -# Include dev dependencies in the dependency graph. Default is false. -include_dev_dependencies: false - # UI verbosity level. Valid values: silent, normal, verbose. Default is normal. # silent: PMG is hidden from the user except for errors and malicious package detection # normal: Show minimal status updates @@ -40,13 +31,9 @@ event_log_retention_days: 7 advisory_message: "" # Proxy configuration. -# When enabled, PMG uses a proxy-based interception approach instead of the -# default guard-based analysis. The proxy intercepts package manager requests in real-time -# and analyzes packages as they are downloaded. Proxy mode may not work in all environments, -# and can be disabled to fall back to the guard-based analysis. +# PMG intercepts package manager requests through a local proxy and analyzes +# packages in real-time as they are downloaded. proxy: - enabled: true - # When true, only install commands are proxied. Other commands # (e.g., npm ls, pip list) bypass the proxy and execute directly. install_only: false diff --git a/config/config_template_test.go b/config/config_template_test.go index 011ff93..97c86f2 100644 --- a/config/config_template_test.go +++ b/config/config_template_test.go @@ -26,9 +26,6 @@ func TestTemplateParsesAsYAML(t *testing.T) { err = v.Unmarshal(&cfg) assert.NoError(t, err, "expected no error while unmarshalling config") - assert.True(t, true, cfg.Transitive, "expected Transitive true") - assert.Equal(t, 5, cfg.TransitiveDepth, "expected TransitiveDepth 5") - assert.False(t, false, cfg.IncludeDevDependencies, "expected IncludeDevDependencies false") assert.False(t, false, cfg.Paranoid, "expected Paranoid false") assert.False(t, cfg.DisableTelemetry, "expected DisableTelemetry false") assert.False(t, false, cfg.SkipEventLogging, "expected SkipEventLogging false") @@ -50,9 +47,6 @@ func TestTemplateMatchesDefaults(t *testing.T) { def := DefaultConfig().Config - assert.Equal(t, def.Transitive, parsed.Transitive, "transitive mismatch") - assert.Equal(t, def.TransitiveDepth, parsed.TransitiveDepth, "transitive_depth mismatch") - assert.Equal(t, def.IncludeDevDependencies, parsed.IncludeDevDependencies, "include_dev_dependencies mismatch") assert.Equal(t, def.Paranoid, parsed.Paranoid, "paranoid mismatch") assert.Equal(t, def.DisableTelemetry, parsed.DisableTelemetry, "disable_telemetry mismatch") assert.Equal(t, def.SkipEventLogging, parsed.SkipEventLogging, "skip_event_logging mismatch") diff --git a/config/config_test.go b/config/config_test.go index 7982c43..ac35549 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -21,9 +21,6 @@ func TestConfigHasDefaultValues(t *testing.T) { initConfig() config := Get() - assert.Equal(t, true, config.Config.Transitive) - assert.Equal(t, 5, config.Config.TransitiveDepth) - assert.Equal(t, false, config.Config.IncludeDevDependencies) assert.Equal(t, false, config.Config.Paranoid) assert.Len(t, config.Config.TrustedPackages, 1) assert.Equal(t, "/tmp/pmg-test/random-does-not-exist", config.configDir) @@ -55,7 +52,7 @@ func TestPartialConfigFallsBackToDefaults(t *testing.T) { // Write a minimal config that only sets a couple of fields, // simulating a user who upgraded PMG without re-running setup - partialConfig := []byte("transitive: false\nparanoid: true\n") + partialConfig := []byte("skip_event_logging: true\nparanoid: true\n") err := os.WriteFile(configPath, partialConfig, 0o644) require.NoError(t, err) @@ -63,13 +60,11 @@ func TestPartialConfigFallsBackToDefaults(t *testing.T) { config := Get() // Explicitly set values should be respected - assert.Equal(t, false, config.Config.Transitive) + assert.Equal(t, true, config.Config.SkipEventLogging) assert.Equal(t, true, config.Config.Paranoid) // Missing keys should fall back to DefaultConfig() values, not Go zero values defaults := DefaultConfig().Config - assert.Equal(t, defaults.TransitiveDepth, config.Config.TransitiveDepth) - assert.Equal(t, defaults.Proxy.Enabled, config.Config.Proxy.Enabled) assert.Equal(t, defaults.Verbosity, config.Config.Verbosity) assert.Equal(t, defaults.EventLogRetentionDays, config.Config.EventLogRetentionDays) assert.Equal(t, defaults.DependencyCooldown.Enabled, config.Config.DependencyCooldown.Enabled) @@ -100,9 +95,8 @@ func TestPartialConfigWithNestedOverride(t *testing.T) { assert.Equal(t, defaults.DependencyCooldown.Enabled, config.Config.DependencyCooldown.Enabled) // Top-level fields should fall back to defaults - assert.Equal(t, defaults.Transitive, config.Config.Transitive) - assert.Equal(t, defaults.TransitiveDepth, config.Config.TransitiveDepth) - assert.Equal(t, defaults.Proxy.Enabled, config.Config.Proxy.Enabled) + assert.Equal(t, defaults.Paranoid, config.Config.Paranoid) + assert.Equal(t, defaults.EventLogRetentionDays, config.Config.EventLogRetentionDays) } func TestProxyInstallOnlyConfig(t *testing.T) { @@ -182,7 +176,7 @@ func TestConfigPrecedence(t *testing.T) { t.Setenv("PMG_PROXY_INSTALL_ONLY", "") configPath := filepath.Join(tmpDir, "config.yml") - err := os.WriteFile(configPath, []byte("transitive: false\n"), 0o644) + err := os.WriteFile(configPath, []byte("paranoid: false\n"), 0o644) require.NoError(t, err) initConfig() @@ -283,7 +277,7 @@ func TestWriteTemplateConfigMergesExistingConfig(t *testing.T) { configPath := filepath.Join(tmpDir, "config.yml") // Write a partial user config - userConfig := []byte("transitive: false\ntransitive_depth: 10\n") + userConfig := []byte("paranoid: true\nevent_log_retention_days: 10\n") err := os.WriteFile(configPath, userConfig, 0o644) require.NoError(t, err) @@ -301,8 +295,8 @@ func TestWriteTemplateConfigMergesExistingConfig(t *testing.T) { raw := string(result) // User values preserved - assert.Contains(t, raw, "transitive: false") - assert.Contains(t, raw, "transitive_depth: 10") + assert.Contains(t, raw, "paranoid: true") + assert.Contains(t, raw, "event_log_retention_days: 10") // New keys from template added assert.Contains(t, raw, "proxy:") @@ -328,12 +322,11 @@ func TestWriteTemplateConfigCreatesNewFile(t *testing.T) { } func TestProxyConfigSection(t *testing.T) { - t.Run("defaults to enabled with install_only false", func(t *testing.T) { + t.Run("defaults to install_only false", func(t *testing.T) { t.Setenv("PMG_CONFIG_DIR", "/tmp/pmg-test/random-does-not-exist") initConfig() cfg := Get() - assert.Equal(t, true, cfg.Config.Proxy.Enabled) assert.Equal(t, false, cfg.Config.Proxy.InstallOnly) assert.NotNil(t, cfg.Config.Proxy.SkipCommands) }) @@ -343,7 +336,6 @@ func TestProxyConfigSection(t *testing.T) { t.Setenv("PMG_CONFIG_DIR", tmpDir) configYAML := `proxy: - enabled: true install_only: true skip_commands: npm: ["my-script", "dev"] @@ -355,7 +347,6 @@ func TestProxyConfigSection(t *testing.T) { initConfig() cfg := Get() - assert.Equal(t, true, cfg.Config.Proxy.Enabled) assert.Equal(t, true, cfg.Config.Proxy.InstallOnly) assert.Equal(t, []string{"my-script", "dev"}, cfg.Config.Proxy.SkipCommands["npm"]) }) @@ -364,8 +355,7 @@ func TestProxyConfigSection(t *testing.T) { tmpDir := t.TempDir() t.Setenv("PMG_CONFIG_DIR", tmpDir) - configYAML := `proxy_mode: false -proxy_install_only: true + configYAML := `proxy_install_only: true ` configPath := filepath.Join(tmpDir, "config.yml") err := os.WriteFile(configPath, []byte(configYAML), 0o644) @@ -374,19 +364,16 @@ proxy_install_only: true initConfig() cfg := Get() - assert.Equal(t, false, cfg.Config.Proxy.Enabled) assert.Equal(t, true, cfg.Config.Proxy.InstallOnly) }) t.Run("falls back to legacy keys from env vars", func(t *testing.T) { t.Setenv("PMG_CONFIG_DIR", "/tmp/pmg-test/random-does-not-exist") - t.Setenv("PMG_PROXY_MODE", "false") t.Setenv("PMG_PROXY_INSTALL_ONLY", "true") initConfig() cfg := Get() - assert.Equal(t, false, cfg.Config.Proxy.Enabled, "PMG_PROXY_MODE=false should set Proxy.Enabled=false") assert.Equal(t, true, cfg.Config.Proxy.InstallOnly, "PMG_PROXY_INSTALL_ONLY=true should set Proxy.InstallOnly=true") }) @@ -394,10 +381,8 @@ proxy_install_only: true tmpDir := t.TempDir() t.Setenv("PMG_CONFIG_DIR", tmpDir) - configYAML := `proxy_mode: false -proxy_install_only: true + configYAML := `proxy_install_only: true proxy: - enabled: true install_only: false ` configPath := filepath.Join(tmpDir, "config.yml") @@ -407,7 +392,6 @@ proxy: initConfig() cfg := Get() - assert.Equal(t, true, cfg.Config.Proxy.Enabled, "new proxy.enabled should win over old proxy_mode") assert.Equal(t, false, cfg.Config.Proxy.InstallOnly, "new proxy.install_only should win over old proxy_install_only") }) } diff --git a/config/managed_config_test.go b/config/managed_config_test.go index 3404d89..54bf3db 100644 --- a/config/managed_config_test.go +++ b/config/managed_config_test.go @@ -25,9 +25,9 @@ func TestManagedConfigTakesPrecedenceAndIgnoresUserFile(t *testing.T) { userDir := t.TempDir() // Global file sets paranoid=true (default is false). User file sets - // transitive=false (default is true) and must be ignored entirely. + // skip_event_logging=true (default is false) and must be ignored entirely. require.NoError(t, os.WriteFile(filepath.Join(globalDir, "config.yml"), []byte("paranoid: true\n"), 0o644)) - require.NoError(t, os.WriteFile(filepath.Join(userDir, "config.yml"), []byte("transitive: false\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(userDir, "config.yml"), []byte("skip_event_logging: true\n"), 0o644)) useManagedConfigDir(t, globalDir) t.Setenv("PMG_CONFIG_DIR", userDir) @@ -39,7 +39,7 @@ func TestManagedConfigTakesPrecedenceAndIgnoresUserFile(t *testing.T) { assert.Equal(t, filepath.Join(userDir, "config.yml"), cfg.UserConfigFilePath()) assert.True(t, cfg.Config.Paranoid, "value should come from the global file") - assert.True(t, cfg.Config.Transitive, "user file must be ignored, so this stays at the template default") + assert.False(t, cfg.Config.SkipEventLogging, "user file must be ignored, so this stays at the template default") } func TestManagedConfigFallsBackToUserWhenGlobalAbsent(t *testing.T) { diff --git a/config/proxy_optout.go b/config/proxy_optout.go new file mode 100644 index 0000000..2f79d10 --- /dev/null +++ b/config/proxy_optout.go @@ -0,0 +1,139 @@ +package config + +import ( + "fmt" + "os" + "strconv" + "strings" + + "github.com/safedep/dry/log" + "github.com/safedep/dry/usefulerror" + "github.com/safedep/pmg/errcodes" +) + +// RejectRemovedProxyOptOut fails loudly when a removed proxy opt-out is still +// in effect. Guard mode is removed and proxy interception can no longer be +// disabled, so a config or environment that explicitly disables it must not be +// silently ignored: the user opted out of proxy interception and switching +// them to it without notice would violate that expectation. +// +// This is a best-effort migration check covering the common opt-out spellings, +// not an exhaustive re-implementation of the old resolution; pathological +// configs are out of scope, and proxy interception runs regardless of its +// outcome. +// +// Resolution mirrors the old order: +// 1. PMG_PROXY_ENABLED (ignored under lockdown) wins over everything. +// Unrecognized values fall back to the default (proxy on), like the old +// loader which silently discarded bad config and ran on defaults. +// 2. The effective proxy.enabled file value, matched like viper resolved it +// (keys case-insensitive, literal dotted proxy.enabled key supported). +// Defaults to true. +// 3. The legacy fallback overrides it when the raw file has no exact "proxy" +// key (the old hasProxySectionInFile gate was case-sensitive): +// PMG_PROXY_MODE (ignored under lockdown) wins over the flat proxy_mode +// file key, both coerced cast.ToBool-style (unparseable = false). +func RejectRemovedProxyOptOut() error { + locked := globalConfig.IsLocked() + + if !locked { + if raw := os.Getenv("PMG_PROXY_ENABLED"); raw != "" { + if enabled, ok := parseOptOutBool(raw); ok && !enabled { + return removedProxyOptOutError("Unset the PMG_PROXY_ENABLED environment variable") + } + + return nil + } + } + + rawKeys, err := readConfigFileKeys(globalConfig.configFilePath) + if err != nil { + if !os.IsNotExist(err) { + log.Warnf("skipping removed proxy opt-out check, could not read config file %s: %v", globalConfig.configFilePath, err) + } + rawKeys = nil + } + + enabled, remedy := true, "" + if value, present := lookupProxyEnabled(rawKeys); present { + if parsed, ok := parseOptOutBool(value); ok { + enabled = parsed + remedy = fmt.Sprintf("Remove proxy.enabled from %s", globalConfig.configFilePath) + } + } + + // The legacy fallback only ran when the raw file had no exact "proxy" key, + // and within it the env var won over the flat file key with cast.ToBool + // coercion (any unparseable value meant false). + if _, hasRawProxyKey := rawKeys["proxy"]; !hasRawProxyKey { + if envRaw := os.Getenv("PMG_PROXY_MODE"); !locked && envRaw != "" { + enabled = legacyBoolValue(envRaw) + remedy = "Unset the PMG_PROXY_MODE environment variable" + } else if value, present := lookupKeyFold(rawKeys, "proxy_mode"); present { + enabled = legacyBoolValue(value) + remedy = fmt.Sprintf("Remove proxy_mode from %s", globalConfig.configFilePath) + } + } + + if !enabled { + return removedProxyOptOutError(remedy) + } + + return nil +} + +// lookupProxyEnabled returns the proxy.enabled value viper would have resolved +// from the raw file keys: an enabled key inside a proxy: section or a literal +// dotted proxy.enabled key, all matched case-insensitively like viper. +func lookupProxyEnabled(raw map[string]any) (any, bool) { + for key, value := range raw { + switch strings.ToLower(key) { + case "proxy": + if section, ok := value.(map[string]any); ok { + if v, present := lookupKeyFold(section, "enabled"); present { + return v, true + } + } + case "proxy.enabled": + return value, true + } + } + + return nil, false +} + +// lookupKeyFold returns the value for key, matching case-insensitively like +// viper's key resolution. +func lookupKeyFold(m map[string]any, key string) (any, bool) { + for k, v := range m { + if strings.ToLower(k) == key { + return v, true + } + } + + return nil, false +} + +// parseOptOutBool reads a boolean out of whatever the YAML parser or the +// environment produced (bool, 0/1 numbers, ParseBool-compatible strings) by +// coercing the value through its string form. Anything else reports no opinion. +func parseOptOutBool(v any) (bool, bool) { + b, err := strconv.ParseBool(fmt.Sprintf("%v", v)) + return b, err == nil +} + +// legacyBoolValue matches cast.ToBool, which the old fallback used via +// v.GetBool: unparseable values (including null) coerce to false instead of +// being ignored, so PMG_PROXY_MODE=off previously selected guard mode. +func legacyBoolValue(v any) bool { + enabled, ok := parseOptOutBool(v) + return ok && enabled +} + +func removedProxyOptOutError(remedy string) error { + return usefulerror.NewUsefulError(). + WithCode(errcodes.InvalidArgument). + WithHumanError("guard mode has been removed and proxy interception can no longer be disabled"). + WithHelp(fmt.Sprintf("%s. If proxy interception does not work in your environment, report it at https://github.com/safedep/pmg/issues", remedy)). + WithMsg("removed proxy opt-out is still configured") +} diff --git a/config/proxy_optout_test.go b/config/proxy_optout_test.go new file mode 100644 index 0000000..ca867af --- /dev/null +++ b/config/proxy_optout_test.go @@ -0,0 +1,245 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRejectRemovedProxyOptOut(t *testing.T) { + cases := []struct { + name string + configYAML string + env map[string]string + wantErr bool + }{ + { + name: "no config file and no env", + wantErr: false, + }, + { + name: "config without proxy keys", + configYAML: "paranoid: false\n", + wantErr: false, + }, + { + name: "proxy.enabled false in config", + configYAML: "proxy:\n enabled: false\n", + wantErr: true, + }, + { + name: "proxy.enabled true in config", + configYAML: "proxy:\n enabled: true\n", + wantErr: false, + }, + { + name: "proxy section without enabled key", + configYAML: "proxy:\n install_only: true\n", + wantErr: false, + }, + { + name: "legacy proxy_mode false in config", + configYAML: "proxy_mode: false\n", + wantErr: true, + }, + { + name: "legacy proxy_mode true in config", + configYAML: "proxy_mode: true\n", + wantErr: false, + }, + { + name: "legacy proxy_mode false ignored when proxy section exists", + configYAML: "proxy_mode: false\nproxy:\n install_only: true\n", + wantErr: false, + }, + { + name: "PMG_PROXY_ENABLED false in env", + env: map[string]string{"PMG_PROXY_ENABLED": "false"}, + wantErr: true, + }, + { + name: "PMG_PROXY_MODE false in env", + env: map[string]string{"PMG_PROXY_MODE": "false"}, + wantErr: true, + }, + { + name: "env true wins over config false", + configYAML: "proxy:\n enabled: false\n", + env: map[string]string{"PMG_PROXY_ENABLED": "true"}, + wantErr: false, + }, + { + name: "unrecognized PMG_PROXY_ENABLED value falls back to proxy default", + env: map[string]string{"PMG_PROXY_ENABLED": "off"}, + wantErr: false, + }, + { + name: "proxy.enabled numeric 0 in config", + configYAML: "proxy:\n enabled: 0\n", + wantErr: true, + }, + { + name: "legacy proxy_mode numeric 0 in config", + configYAML: "proxy_mode: 0\n", + wantErr: true, + }, + { + name: "PMG_PROXY_MODE true does not override proxy.enabled false in file", + configYAML: "proxy:\n enabled: false\n", + env: map[string]string{"PMG_PROXY_MODE": "true"}, + wantErr: true, + }, + { + name: "PMG_PROXY_MODE false is inert when proxy section exists", + configYAML: "proxy:\n install_only: true\n", + env: map[string]string{"PMG_PROXY_MODE": "false"}, + wantErr: false, + }, + { + name: "null proxy section makes legacy proxy_mode inert", + configYAML: "proxy:\nproxy_mode: false\n", + wantErr: false, + }, + { + name: "PMG_PROXY_MODE true wins over flat proxy_mode false in file", + configYAML: "proxy_mode: false\n", + env: map[string]string{"PMG_PROXY_MODE": "true"}, + wantErr: false, + }, + { + name: "PMG_PROXY_ENABLED true wins over legacy flat proxy_mode false", + configYAML: "proxy_mode: false\n", + env: map[string]string{"PMG_PROXY_ENABLED": "true"}, + wantErr: false, + }, + { + name: "capitalized Proxy section with enabled false", + configYAML: "Proxy:\n enabled: false\n", + wantErr: true, + }, + { + name: "capitalized Enabled key in proxy section", + configYAML: "proxy:\n Enabled: false\n", + wantErr: true, + }, + { + name: "literal dotted proxy.enabled key", + configYAML: "proxy.enabled: false\n", + wantErr: true, + }, + { + name: "capitalized legacy Proxy_Mode key", + configYAML: "Proxy_Mode: false\n", + wantErr: true, + }, + { + name: "literal dotted proxy.enabled true is not an opt-out", + configYAML: "proxy.enabled: true\n", + wantErr: false, + }, + { + name: "case-variant Proxy section does not gate the legacy flat key", + configYAML: "Proxy:\n install_only: true\nproxy_mode: false\n", + wantErr: true, + }, + { + name: "dotted proxy.enabled false was overridden by legacy proxy_mode true", + configYAML: "proxy.enabled: false\nproxy_mode: true\n", + wantErr: false, + }, + { + name: "null proxy key with dotted proxy.enabled false is deterministic", + configYAML: "proxy:\nproxy.enabled: false\n", + wantErr: true, + }, + { + name: "PMG_PROXY_MODE unparseable value coerced to false like cast.ToBool", + env: map[string]string{"PMG_PROXY_MODE": "off"}, + wantErr: true, + }, + { + name: "flat proxy_mode unparseable value coerced to false", + configYAML: "proxy_mode: \"off\"\n", + wantErr: true, + }, + { + name: "unrecognized proxy.enabled value falls back to proxy default", + configYAML: "proxy:\n enabled: yes\n", + wantErr: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("PMG_CONFIG_DIR", tmpDir) + + if tc.configYAML != "" { + err := os.WriteFile(filepath.Join(tmpDir, "config.yml"), []byte(tc.configYAML), 0o644) + require.NoError(t, err) + } + + for key, value := range tc.env { + t.Setenv(key, value) + } + + initConfig() + + err := RejectRemovedProxyOptOut() + if tc.wantErr { + require.Error(t, err) + require.Contains(t, err.Error(), "removed proxy opt-out is still configured") + } else { + require.NoError(t, err) + } + }) + } +} + +// A locked (managed) config ignored env vars entirely in the old resolution, +// so under lockdown env opt-outs must not trigger and env enables must not +// rescue a file opt-out. +func TestRejectRemovedProxyOptOutLockedIgnoresEnv(t *testing.T) { + t.Run("env opt-out is inert under lockdown", func(t *testing.T) { + globalDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(globalDir, "config.yml"), []byte("global_lockdown: true\n"), 0o644)) + + useManagedConfigDir(t, globalDir) + t.Setenv("PMG_PROXY_ENABLED", "false") + initConfig() + + require.True(t, Get().IsLocked()) + require.NoError(t, RejectRemovedProxyOptOut()) + }) + + t.Run("env enable cannot rescue a file opt-out under lockdown", func(t *testing.T) { + globalDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(globalDir, "config.yml"), []byte("global_lockdown: true\nproxy_mode: false\n"), 0o644)) + + useManagedConfigDir(t, globalDir) + t.Setenv("PMG_PROXY_ENABLED", "true") + t.Setenv("PMG_PROXY_MODE", "true") + initConfig() + + require.True(t, Get().IsLocked()) + require.Error(t, RejectRemovedProxyOptOut()) + }) +} + +// Colliding spellings must resolve the same way on every invocation: map +// iteration order varies per parse, so repeat the check to catch order +// dependent resolution (this was an observed 42-in-50 flake before). +func TestRejectRemovedProxyOptOutDeterministic(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("PMG_CONFIG_DIR", tmpDir) + + configYAML := "proxy:\nproxy.enabled: false\n" + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "config.yml"), []byte(configYAML), 0o644)) + initConfig() + + for range 25 { + require.Error(t, RejectRemovedProxyOptOut()) + } +} diff --git a/config/trusted.go b/config/trusted.go index f108ff3..9d01d27 100644 --- a/config/trusted.go +++ b/config/trusted.go @@ -5,7 +5,7 @@ import ( ) // IsTrustedPackage checks if a package version is trusted based on global configuration. -// This is the primary API that should be used by guard and proxy flows. +// This is the primary API that should be used by the proxy flow. // It returns true if the package is in the trusted packages list, false otherwise. func IsTrustedPackage(pkgVersion *packagev1.PackageVersion) bool { return isTrustedPackageVersion(Get().Config.TrustedPackages, pkgVersion) diff --git a/config/viper.go b/config/viper.go index 2e05176..034be35 100644 --- a/config/viper.go +++ b/config/viper.go @@ -120,18 +120,12 @@ func globalConfigEnablesLockdown(path string) bool { // applyProxyLegacyFallback populates the new Proxy struct from deprecated // flat keys when the user's config file does not have a proxy: section. -// New env vars (PMG_PROXY_ENABLED, PMG_PROXY_INSTALL_ONLY) take precedence -// over legacy config file keys to respect the documented precedence order. +// The new env var (PMG_PROXY_INSTALL_ONLY) takes precedence over legacy +// config file keys to respect the documented precedence order. func applyProxyLegacyFallback(v *viper.Viper) { // A locked config ignores env, so env must not suppress the legacy migration. envIgnored := globalConfig.IsLocked() - if (envIgnored || os.Getenv("PMG_PROXY_ENABLED") == "") && v.IsSet("proxy_mode") { - val := v.GetBool("proxy_mode") - globalConfig.Config.Proxy.Enabled = val - v.Set("proxy.enabled", val) - } - if (envIgnored || os.Getenv("PMG_PROXY_INSTALL_ONLY") == "") && v.IsSet("proxy_install_only") { val := v.GetBool("proxy_install_only") globalConfig.Config.Proxy.InstallOnly = val diff --git a/config/viper_env_test.go b/config/viper_env_test.go index d530aa7..84d4a73 100644 --- a/config/viper_env_test.go +++ b/config/viper_env_test.go @@ -10,21 +10,6 @@ import ( ) func TestLegacyProxyFallbackSyncsToViper(t *testing.T) { - t.Run("proxy_mode synced to viper for GetConfigValue", func(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("PMG_CONFIG_DIR", tmpDir) - - configPath := filepath.Join(tmpDir, "config.yml") - err := os.WriteFile(configPath, []byte("proxy_mode: false\n"), 0o644) - require.NoError(t, err) - - initConfig() - - val, err := GetConfigValue("proxy.enabled") - require.NoError(t, err) - assert.Equal(t, false, val) - }) - t.Run("proxy_install_only synced to viper for GetConfigValue", func(t *testing.T) { tmpDir := t.TempDir() t.Setenv("PMG_CONFIG_DIR", tmpDir) @@ -42,20 +27,6 @@ func TestLegacyProxyFallbackSyncsToViper(t *testing.T) { } func TestNewEnvVarNotOverriddenByLegacyConfigFile(t *testing.T) { - t.Run("PMG_PROXY_ENABLED wins over proxy_mode in config file", func(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("PMG_CONFIG_DIR", tmpDir) - t.Setenv("PMG_PROXY_ENABLED", "false") - - configPath := filepath.Join(tmpDir, "config.yml") - err := os.WriteFile(configPath, []byte("proxy_mode: true\n"), 0o644) - require.NoError(t, err) - - initConfig() - assert.Equal(t, false, Get().Config.Proxy.Enabled, - "PMG_PROXY_ENABLED=false should not be overridden by proxy_mode: true in config") - }) - t.Run("PMG_PROXY_INSTALL_ONLY wins over proxy_install_only in config file", func(t *testing.T) { tmpDir := t.TempDir() t.Setenv("PMG_CONFIG_DIR", tmpDir) diff --git a/config/yaml_set_test.go b/config/yaml_set_test.go index 469ac6e..35b1979 100644 --- a/config/yaml_set_test.go +++ b/config/yaml_set_test.go @@ -232,10 +232,10 @@ func TestSetConfigValue(t *testing.T) { } func TestGetConfigValue(t *testing.T) { - configYAML := "paranoid: true\ntransitive: false\ntransitive_depth: 10\nverbosity: verbose\n" + + configYAML := "paranoid: true\nskip_event_logging: false\nevent_log_retention_days: 10\nverbosity: verbose\n" + "cloud:\n enabled: true\n endpoint_id: ep-123\n" + "dependency_cooldown:\n enabled: true\n days: 7\n" + - "proxy:\n enabled: false\n install_only: true\n" + + "proxy:\n install_only: true\n" + "sandbox:\n enabled: true\n enforce_always: false\n" setupConfig := func(t *testing.T) { @@ -254,13 +254,12 @@ func TestGetConfigValue(t *testing.T) { wantErr string }{ {name: "top-level bool true", key: "paranoid", expected: true}, - {name: "top-level bool false", key: "transitive", expected: false}, - {name: "top-level integer", key: "transitive_depth", expected: 10}, + {name: "top-level bool false", key: "skip_event_logging", expected: false}, + {name: "top-level integer", key: "event_log_retention_days", expected: 10}, {name: "top-level string", key: "verbosity", expected: "verbose"}, {name: "nested bool", key: "cloud.enabled", expected: true}, {name: "nested string", key: "cloud.endpoint_id", expected: "ep-123"}, {name: "nested integer", key: "dependency_cooldown.days", expected: 7}, - {name: "nested bool under proxy", key: "proxy.enabled", expected: false}, {name: "nested bool under proxy install_only", key: "proxy.install_only", expected: true}, {name: "nested bool under sandbox", key: "sandbox.enabled", expected: true}, {name: "nested bool under sandbox enforce_always", key: "sandbox.enforce_always", expected: false}, @@ -301,7 +300,7 @@ func TestGetConfigValue(t *testing.T) { t.Setenv("PMG_CONFIG_DIR", "/tmp/pmg-test/random-does-not-exist") initConfig() - val, err := GetConfigValue("transitive") + val, err := GetConfigValue("dependency_cooldown.enabled") require.NoError(t, err) assert.Equal(t, true, val) @@ -404,7 +403,7 @@ func TestSetThenGetRoundTrip(t *testing.T) { t.Setenv("PMG_CONFIG_DIR", tmpDir) configPath := filepath.Join(tmpDir, "config.yml") - err := os.WriteFile(configPath, []byte("paranoid: false\ntransitive_depth: 5\nverbosity: normal\n"), 0o644) + err := os.WriteFile(configPath, []byte("paranoid: false\nevent_log_retention_days: 5\nverbosity: normal\n"), 0o644) require.NoError(t, err) initConfig() @@ -412,7 +411,7 @@ func TestSetThenGetRoundTrip(t *testing.T) { err = SetConfigValue("paranoid", "true") require.NoError(t, err) - err = SetConfigValue("transitive_depth", "20") + err = SetConfigValue("event_log_retention_days", "20") require.NoError(t, err) err = SetConfigValue("verbosity", "silent") @@ -425,7 +424,7 @@ func TestSetThenGetRoundTrip(t *testing.T) { require.NoError(t, err) assert.Equal(t, true, val) - val, err = GetConfigValue("transitive_depth") + val, err = GetConfigValue("event_log_retention_days") require.NoError(t, err) assert.Equal(t, 20, val) diff --git a/docs/analysis-cache.md b/docs/analysis-cache.md index 08705cc..0b75fcc 100644 --- a/docs/analysis-cache.md +++ b/docs/analysis-cache.md @@ -60,7 +60,7 @@ in doubt, keep the cache disabled (the default) or use a short `ttl`. ## Requirements -The analysis cache applies to [proxy mode](proxy-mode.md). It is independent of +The analysis cache is independent of [dependency cooldown](dependency-cooldown.md): cooldown decides which *versions* are eligible to install, while the analysis cache remembers malware verdicts for versions that were already screened. diff --git a/docs/config.md b/docs/config.md index 4224295..e49e9f7 100644 --- a/docs/config.md +++ b/docs/config.md @@ -29,7 +29,7 @@ To set a config value: ```bash pmg config set paranoid true -pmg config set transitive_depth 10 +pmg config set dependency_cooldown.days 10 pmg config set cloud.enabled true ``` See [config template](../config/config.template.yml) for the configuration schema. @@ -43,9 +43,7 @@ file. This is useful for CI/CD pipelines or temporary overrides. | Config key | Environment variable | |---|---| -| `transitive` | `PMG_TRANSITIVE` | | `paranoid` | `PMG_PARANOID` | -| `proxy.enabled` | `PMG_PROXY_ENABLED` | | `proxy.install_only` | `PMG_PROXY_INSTALL_ONLY` | | `verbosity` | `PMG_VERBOSITY` | | `skip_event_logging` | `PMG_SKIP_EVENT_LOGGING` | @@ -53,7 +51,9 @@ file. This is useful for CI/CD pipelines or temporary overrides. | `dependency_cooldown.enabled` | `PMG_DEPENDENCY_COOLDOWN_ENABLED` | | `cloud.enabled` | `PMG_CLOUD_ENABLED` | -Legacy environment variables `PMG_PROXY_MODE` and `PMG_PROXY_INSTALL_ONLY` (for the old flat keys) are still supported when the `proxy:` section does not exist in the config file. +The legacy flat key `proxy_install_only` is still supported when the `proxy:` section does not exist in the config file. + +Proxy interception can no longer be disabled: PMG fails with an error when the config or environment still contains `proxy.enabled: false`, `proxy_mode: false`, `PMG_PROXY_ENABLED=false` or `PMG_PROXY_MODE=false`. See [proxy mode](proxy-mode.md). **Example:** @@ -128,7 +128,7 @@ global_lockdown: true When lockdown is on: -- **CLI flags that would change a managed value fail fast.** For example, `pmg --sandbox=false ...` or `pmg --paranoid ...` errors out instead of overriding policy. Governed flags: `--transitive`, `--transitive-depth`, `--include-dev-dependencies`, `--paranoid`, `--skip-event-log`, `--proxy-mode`, `--sandbox`, `--sandbox-enforce`, `--sandbox-profile`, `--sandbox-allow`, `--skip-dependency-cooldown`. Operational flags such as `--dry-run` keep working. +- **CLI flags that would change a managed value fail fast.** For example, `pmg --sandbox=false ...` or `pmg --paranoid ...` errors out instead of overriding policy. Governed flags: `--paranoid`, `--skip-event-log`, `--sandbox`, `--sandbox-enforce`, `--sandbox-profile`, `--sandbox-allow`, `--skip-dependency-cooldown`. Operational flags such as `--dry-run` keep working. - **`PMG_*` variables cannot change the config**, including `PMG_INSECURE_INSTALLATION` (which otherwise bypasses malicious-package blocking). PMG reads `global_lockdown` straight from the global file, so a user cannot flip it through env or CLI. If the global file exists but cannot be read or parsed, PMG fails closed and treats it as locked. `PMG_CONFIG_DIR` and `PMG_CACHE_DIR` still relocate per-user state directories (logs, cache) in any mode, but leave the managed config alone. diff --git a/docs/dependency-cooldown.md b/docs/dependency-cooldown.md index 2da886d..0f98692 100644 --- a/docs/dependency-cooldown.md +++ b/docs/dependency-cooldown.md @@ -76,7 +76,7 @@ pmg --skip-dependency-cooldown npm install express ## Requirements -Dependency cooldown requires [proxy mode](proxy-mode.md) to be enabled. It is supported for npm and PyPI packages. +Dependency cooldown is supported for npm and PyPI packages. ## Limitations diff --git a/docs/github-action.md b/docs/github-action.md index 12f91b3..96df27b 100644 --- a/docs/github-action.md +++ b/docs/github-action.md @@ -50,7 +50,7 @@ explicitly to override. | `paranoid` | `PMG_PARANOID` | `false` | | `cooldown-enabled` | `PMG_DEPENDENCY_COOLDOWN_ENABLED` | `true` | | `cooldown-days` | `PMG_DEPENDENCY_COOLDOWN_DAYS` | `5` | -| `proxy-mode` | `PMG_PROXY_ENABLED`. Set `false` for guard-based analysis | `true` | +| `proxy-mode` | REMOVED. Proxy interception can no longer be disabled; `"false"` fails the action, other values warn and are ignored | unset | | `sandbox` | `PMG_SANDBOX_ENABLED`. Also relaxes AppArmor user-ns restrictions on the runner | `false` | | `sandbox-driver` | `PMG_SANDBOX_DRIVER` — `landlock` or `bubblewrap` | `landlock` when sandbox is enabled | | `verbosity` | `PMG_VERBOSITY` — `silent`, `normal`, or `verbose` | `normal` | @@ -144,7 +144,7 @@ action input. Set it on the job or the install step: - uses: safedep/pmg@v1 - run: npm ci env: - PMG_TRANSITIVE_DEPTH: 10 + PMG_DEPENDENCY_COOLDOWN_DAYS: 10 ``` See [docs/config.md](./config.md) for the full mapping. diff --git a/docs/package-manager.md b/docs/package-manager.md index de2feb0..621690f 100644 --- a/docs/package-manager.md +++ b/docs/package-manager.md @@ -22,29 +22,24 @@ Use this checklist to add a new package manager ecosystem (e.g., npm or PyPI). K - Multiple dependencies - Edge cases (e.g., missing fields, malformed entries) -5. Create an extractor - - In `extractor/`, add an extractor for the new ecosystem under `extractor/npm` or `extractor/pypi`. - - Update the `NewExtractorManager` to include the newly introduced `PackageManagerExtractor`. - - Update `getExtractorForFile` to recognize and support the ecosystem’s manifests/lockfiles. - -6. Register alias +5. Register alias - In `internal/alias/alias.go`, add the new package manager’s alias to `DefaultConfig.packageManagers`. - Verify default alias and invocation match conventions. -7. Add analytics +6. Add analytics - Define a new analytics event similar to existing ones. - Implement a `Track` function for the event. - Invoke tracking in the new package manager cmd. -8. Update documentation +7. Update documentation - Update the README to list the new supported package manager. - Add usage examples consistent with existing examples. -9. Add e2e workflow +8. Add e2e workflow - In `.github/workflows/pmg-e2e.yml`, add an e2e job for the new manager. - Mirror structure and steps used by other ecosystems. -10. Verify end-to-end behavior +9. Verify end-to-end behavior - Test the CLI locally for: - Single package installation - Multiple package installation @@ -53,6 +48,6 @@ Use this checklist to add a new package manager ecosystem (e.g., npm or PyPI). K - Manifests/lockfiles installation flow - `pmg setup install` to verify alias is set and works -11. Consistency pass +10. Consistency pass - Confirm naming, errors, logs, and UX align with existing ecosystems. - Ensure code follows project patterns and is covered by tests. diff --git a/docs/proxy-mode.md b/docs/proxy-mode.md index 228857c..c47e4fc 100644 --- a/docs/proxy-mode.md +++ b/docs/proxy-mode.md @@ -1,6 +1,6 @@ # Proxy Mode -PMG supports proxy based interception as an alternative to the current optimistic dependency resolution. When enabled: +PMG protects package installations through proxy based interception: - PMG starts a micro-proxy server on a random localhost port - Runs `npm` and other supported package managers configured to use the proxy @@ -19,12 +19,11 @@ Proxy behavior is configured under the `proxy:` section in `config.yml`: ```yaml proxy: - enabled: true + install_only: false ``` | Key | Default | Description | |---|---|---| -| `enabled` | `true` | Enable proxy-based interception. When `false`, PMG falls back to guard-based analysis. | | `install_only` | `false` | When `true`, only install commands are proxied. Other commands (e.g., `npm ls`, `pip list`) bypass the proxy and execute directly. | | `skip_commands` | `{}` | Per-package-manager commands to bypass the proxy. Only applies when `install_only` is `true`. | @@ -42,18 +41,19 @@ proxy: Commands in `skip_commands` are matched against the first non-flag argument. For example, `npm dev` would match `dev`, but `npm install dev` would not since `install` is the first non-flag argument. -### CLI flags - -Use `--proxy-mode` to override `proxy.enabled` at runtime. - ### Environment variables | Variable | Description | |---|---| -| `PMG_PROXY_ENABLED` | Override `proxy.enabled` | | `PMG_PROXY_INSTALL_ONLY` | Override `proxy.install_only` | -Legacy variables `PMG_PROXY_MODE` and `PMG_PROXY_INSTALL_ONLY` (for the old flat config keys) are still supported when the `proxy:` section does not exist in the config file. +The legacy flat config key `proxy_install_only` is still supported when the `proxy:` section does not exist in the config file. + +### Removed: disabling proxy interception + +Guard mode (the non-proxy analysis flow) has been removed and proxy interception can no longer be disabled. PMG fails with an error when it detects a leftover opt-out — `proxy.enabled: false` or `proxy_mode: false` in the config file, `PMG_PROXY_ENABLED=false` or `PMG_PROXY_MODE=false` in the environment — instead of silently switching to proxy interception. Remove the setting to proceed. The `--proxy-mode` flag is removed and fails as an unknown flag. + +Note one trade-off versus the removed guard mode: the proxy analyzes packages as they are downloaded, so installs fully served from a local package manager cache (e.g. npm cache, pnpm store, pip cache, `--offline` installs) do not trigger analysis. Guard mode analyzed manifest-listed packages via registry metadata regardless of downloads. Packages are analyzed when first fetched through the proxy, which is when they enter those caches. ## Supported Package Managers diff --git a/docs/sandbox-landlock.md b/docs/sandbox-landlock.md index dcc15ec..bcfea11 100644 --- a/docs/sandbox-landlock.md +++ b/docs/sandbox-landlock.md @@ -133,8 +133,8 @@ constant tax that maps to most of the decisions above: - **Unprivileged user namespaces required.** On distros that disable them, `clone()` returns EPERM. We don't yet probe and fall back to bubblewrap (TODO). -- **Network filtering not enforced.** Landlock V4 does TCP ports, not hostnames. Use - proxy-mode. +- **Network filtering not enforced.** Landlock V4 does TCP ports, not hostnames. PMG's + proxy interception provides network control. - **PID/IPC namespace isolation is best-effort.** Retried without on EPERM. - **Audit events are dropped.** Wired but consumed by `io.Discard`. - **TOCTOU between path read and deny response.** Microseconds. Adequate for benign diff --git a/docs/sandbox.md b/docs/sandbox.md index 9ee6705..f188d2c 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -436,7 +436,7 @@ on Debian/Ubuntu; default on most modern distros). If disabled, the helper fails EPERM on `clone()` and the sandbox falls back to Bubblewrap. **Network filtering**: Not enforced. Landlock supports TCP port filtering only (V4+, no hostname). -Use `--proxy-mode` for network control. +PMG's proxy interception provides network control. **PID/IPC namespace isolation**: Applied best-effort via `CLONE_NEWPID|CLONE_NEWIPC|CLONE_NEWNS`. If unavailable, a warning is printed and the command continues. Set `PMG_SANDBOX_DRIVER=bubblewrap` diff --git a/errcodes/codes.go b/errcodes/codes.go index 6fce035..b6d3003 100644 --- a/errcodes/codes.go +++ b/errcodes/codes.go @@ -14,10 +14,9 @@ const ( BubblewrapNotFound = "BubblewrapNotFound" // Package manager error codes. - DependencyResolutionFailed = "DependencyResolutionFailed" - PackageParseFailed = "PackageParseFailed" - PackageAuthorNotFound = "PackageAuthorNotFound" - GitHubRateLimitExceeded = "GitHubRateLimitExceeded" + PackageParseFailed = "PackageParseFailed" + PackageAuthorNotFound = "PackageAuthorNotFound" + GitHubRateLimitExceeded = "GitHubRateLimitExceeded" // Certificate trust store error codes. CertGeneration = "CertGeneration" diff --git a/extractor/common.go b/extractor/common.go deleted file mode 100644 index 9254904..0000000 --- a/extractor/common.go +++ /dev/null @@ -1,91 +0,0 @@ -package extractor - -import ( - "context" - "fmt" - "os" - "path/filepath" - "regexp" - - packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" - "github.com/google/osv-scalibr/extractor/filesystem" - "github.com/google/osv-scalibr/extractor/filesystem/language/javascript/bunlock" - "github.com/google/osv-scalibr/extractor/filesystem/language/javascript/packagelockjson" - "github.com/google/osv-scalibr/extractor/filesystem/language/javascript/pnpmlock" - "github.com/google/osv-scalibr/extractor/filesystem/language/javascript/yarnlock" - "github.com/google/osv-scalibr/extractor/filesystem/language/python/poetrylock" - "github.com/google/osv-scalibr/extractor/filesystem/language/python/requirements" - "github.com/google/osv-scalibr/extractor/filesystem/language/python/uvlock" - "github.com/google/osv-scalibr/fs" - "github.com/safedep/dry/log" -) - -func getExtractorForFile(filename string) (filesystem.Extractor, error) { - filename = filepath.Base(filename) - - // Regex for requirements files (match requirements.txt and requirements-{word}.txt) - reqPattern := regexp.MustCompile(`^requirements(?:-\w+)?\.txt$`) - - switch { - case filename == "package-lock.json": - return packagelockjson.NewDefault(), nil - case filename == "pnpm-lock.yaml": - return pnpmlock.New(), nil - case filename == "bun.lock": - return bunlock.New(), nil - case filename == "yarn.lock": - return yarnlock.New(), nil - case reqPattern.MatchString(filename): - return requirements.NewDefault(), nil - case filename == "uv.lock": - return uvlock.New(), nil - case filename == "poetry.lock": - return poetrylock.New(), nil - default: - return nil, fmt.Errorf("unsupported lockfile type: %s", filename) - } -} - -func parseLockfile(lockfilePath, scanDir string, ecosystem packagev1.Ecosystem) ([]*packagev1.PackageVersion, error) { - extractor, err := getExtractorForFile(lockfilePath) - if err != nil { - return nil, err - } - - file, err := os.Open(lockfilePath) - if err != nil { - return nil, fmt.Errorf("failed to open lockfile: %w", err) - } - defer func() { - if err := file.Close(); err != nil { - log.Warnf("failed to close lockfile: %v", err) - } - }() - - inputConfig := &filesystem.ScanInput{ - FS: fs.DirFS(scanDir), - Path: lockfilePath, - Reader: file, - } - - inventory, err := extractor.Extract(context.Background(), inputConfig) - if err != nil { - return nil, fmt.Errorf("failed to extract packages: %w", err) - } - - var packages []*packagev1.PackageVersion - - for _, invPkg := range inventory.Packages { - pkg := &packagev1.PackageVersion{ - Package: &packagev1.Package{ - Name: invPkg.Name, - Ecosystem: ecosystem, - }, - Version: invPkg.Version, - } - - packages = append(packages, pkg) - } - - return packages, nil -} diff --git a/extractor/ecosystems.go b/extractor/ecosystems.go deleted file mode 100644 index 406d80f..0000000 --- a/extractor/ecosystems.go +++ /dev/null @@ -1,60 +0,0 @@ -package extractor - -import ( - packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" -) - -// PackageManagerExtractor defines the interface for package-manager-specific extractors -type PackageManagerExtractor interface { - // Returns the list of supported lockfiles by the package manager - GetSupportedFiles() []string - - // Returns the package manager ecosysetm - GetEcosystem() packagev1.Ecosystem - - // Returns the package manager name - GetPackageManager() PackageManagerName - - // Extracts the packages from the lockfile - Extract(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) -} - -type PackageManagerName string - -const ( - Npm PackageManagerName = "npm" - Pnpm PackageManagerName = "pnpm" - Pip PackageManagerName = "pip" - Pip3 PackageManagerName = "pip3" - Bun PackageManagerName = "bun" - Yarn PackageManagerName = "yarn" - Uv PackageManagerName = "uv" - Poetry PackageManagerName = "poetry" -) - -type ExtractorManager struct { - extractors map[PackageManagerName]PackageManagerExtractor -} - -func NewExtractorManager() *ExtractorManager { - return &ExtractorManager{ - extractors: map[PackageManagerName]PackageManagerExtractor{ - Npm: &NpmExtractor{}, - Pnpm: &PnpmExtractor{}, - Pip: &PipExtractor{}, - Bun: &BunExtractor{}, - Yarn: &YarnExtractor{}, - Uv: &UvExtractor{}, - Poetry: &PoetryExtractor{}, - Pip3: &Pip3Extractor{}, - }, - } -} - -func (e *ExtractorManager) GetExtractorForPackageManager(pmn PackageManagerName) PackageManagerExtractor { - return e.extractors[pmn] -} - -func (e *ExtractorManager) GetSupportedFilesForPackageManager(pmn PackageManagerName) []string { - return e.extractors[pmn].GetSupportedFiles() -} diff --git a/extractor/extractor.go b/extractor/extractor.go deleted file mode 100644 index d798eac..0000000 --- a/extractor/extractor.go +++ /dev/null @@ -1,84 +0,0 @@ -package extractor - -import ( - "fmt" - "os" - "path/filepath" - - packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" - "github.com/safedep/dry/log" -) - -type ExtractorConfig struct { - ExtractorPackageManager PackageManagerName - ScanDir string - ManifestFiles []string -} - -type extractor struct { - Config ExtractorConfig - extractorManager ExtractorManager -} - -func NewDefaultExtractorConfig() *ExtractorConfig { - return &ExtractorConfig{ - ScanDir: ".", - ManifestFiles: []string{}, - } -} - -func New(config ExtractorConfig) *extractor { - return &extractor{ - Config: config, - extractorManager: *NewExtractorManager(), - } -} - -func (e *extractor) ExtractManifest() ([]*packagev1.PackageVersion, error) { - packagesToAnalyze := []*packagev1.PackageVersion{} - - // Get the list of lockfiles to check based on ecosystem - filesToCheck := e.Config.ManifestFiles - - if len(filesToCheck) == 0 { - filesToCheck = e.getFilesToCheck() - } - - for _, filename := range filesToCheck { - filePath := filepath.Join(e.Config.ScanDir, filename) - - // Check if the file exists - if _, err := os.Stat(filePath); os.IsNotExist(err) { - continue - } - - extractor, err := e.getExtractorForFile() - if err != nil { - log.Warnf("failed to get extractor\n") - continue - } - - // Extract packages from this lockfile - packages, err := extractor.Extract(filePath, e.Config.ScanDir) - if err != nil { - log.Warnf("failed to extract from %s: %v\n", filePath, err) - continue - } - - packagesToAnalyze = append(packagesToAnalyze, packages...) - } - - return packagesToAnalyze, nil -} - -func (e *extractor) getFilesToCheck() []string { - return e.extractorManager.GetSupportedFilesForPackageManager(e.Config.ExtractorPackageManager) -} - -func (e *extractor) getExtractorForFile() (PackageManagerExtractor, error) { - extractor := e.extractorManager.GetExtractorForPackageManager(e.Config.ExtractorPackageManager) - if extractor == nil { - return nil, fmt.Errorf("no extractor found for the specified package manager: %s", e.Config.ExtractorPackageManager) - } - return extractor, nil -} diff --git a/extractor/npm.go b/extractor/npm.go deleted file mode 100644 index 93e5f89..0000000 --- a/extractor/npm.go +++ /dev/null @@ -1,79 +0,0 @@ -package extractor - -import ( - packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" -) - -// NpmExtractor handles package-lock.json files -type NpmExtractor struct{} - -func (n *NpmExtractor) GetSupportedFiles() []string { - return []string{"package-lock.json"} -} - -func (n *NpmExtractor) GetEcosystem() packagev1.Ecosystem { - return packagev1.Ecosystem_ECOSYSTEM_NPM -} - -func (n *NpmExtractor) GetPackageManager() PackageManagerName { - return Npm -} - -func (n *NpmExtractor) Extract(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) { - return parseLockfile(lockfilePath, scanDir, n.GetEcosystem()) -} - -// PnpmExtractor handles pnpm-lock.yaml files -type PnpmExtractor struct{} - -func (p *PnpmExtractor) GetSupportedFiles() []string { - return []string{"pnpm-lock.yaml"} -} - -func (p *PnpmExtractor) GetEcosystem() packagev1.Ecosystem { - return packagev1.Ecosystem_ECOSYSTEM_NPM -} - -func (p *PnpmExtractor) GetPackageManager() PackageManagerName { - return Pnpm -} - -func (p *PnpmExtractor) Extract(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) { - return parseLockfile(lockfilePath, scanDir, p.GetEcosystem()) -} - -type BunExtractor struct{} - -func (n *BunExtractor) GetSupportedFiles() []string { - return []string{"bun.lock"} -} - -func (n *BunExtractor) GetEcosystem() packagev1.Ecosystem { - return packagev1.Ecosystem_ECOSYSTEM_NPM -} - -func (n *BunExtractor) GetPackageManager() PackageManagerName { - return Bun -} - -func (n *BunExtractor) Extract(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) { - return parseLockfile(lockfilePath, scanDir, n.GetEcosystem()) -} - -type YarnExtractor struct{} - -func (y *YarnExtractor) GetSupportedFiles() []string { - return []string{"yarn.lock"} -} - -func (y *YarnExtractor) GetEcosystem() packagev1.Ecosystem { - return packagev1.Ecosystem_ECOSYSTEM_NPM -} - -func (y *YarnExtractor) GetPackageManager() PackageManagerName { - return Yarn -} - -func (y *YarnExtractor) Extract(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) { - return parseLockfile(lockfilePath, scanDir, y.GetEcosystem()) -} \ No newline at end of file diff --git a/extractor/pypi.go b/extractor/pypi.go deleted file mode 100644 index 00b7a40..0000000 --- a/extractor/pypi.go +++ /dev/null @@ -1,81 +0,0 @@ -package extractor - -import ( - packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" -) - -// PipExtractor handles requirements.txt files -type PipExtractor struct{} - -func (p *PipExtractor) GetSupportedFiles() []string { - return []string{"requirements.txt"} -} - -func (p *PipExtractor) GetEcosystem() packagev1.Ecosystem { - return packagev1.Ecosystem_ECOSYSTEM_PYPI -} - -func (p *PipExtractor) GetPackageManager() PackageManagerName { - return Pip -} - -func (p *PipExtractor) Extract(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) { - return parseLockfile(lockfilePath, scanDir, p.GetEcosystem()) -} - -// UvExtractor handles uv.lock files -type UvExtractor struct{} - -func (u *UvExtractor) GetSupportedFiles() []string { - return []string{"uv.lock"} -} - -func (u *UvExtractor) GetEcosystem() packagev1.Ecosystem { - return packagev1.Ecosystem_ECOSYSTEM_PYPI -} - -func (u *UvExtractor) GetPackageManager() PackageManagerName { - return Uv -} - -func (u *UvExtractor) Extract(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) { - return parseLockfile(lockfilePath, scanDir, u.GetEcosystem()) -} - -// PoetryExtractor handles poetry.lock files -type PoetryExtractor struct{} - -func (p *PoetryExtractor) GetSupportedFiles() []string { - return []string{"poetry.lock"} -} - -func (p *PoetryExtractor) GetEcosystem() packagev1.Ecosystem { - return packagev1.Ecosystem_ECOSYSTEM_PYPI -} - -func (p *PoetryExtractor) GetPackageManager() PackageManagerName { - return Poetry -} - -func (p *PoetryExtractor) Extract(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) { - return parseLockfile(lockfilePath, scanDir, p.GetEcosystem()) -} - -// Pip3Extractor handles requirements.txt files -type Pip3Extractor struct{} - -func (p *Pip3Extractor) GetSupportedFiles() []string { - return []string{"requirements.txt"} -} - -func (p *Pip3Extractor) GetEcosystem() packagev1.Ecosystem { - return packagev1.Ecosystem_ECOSYSTEM_PYPI -} - -func (p *Pip3Extractor) GetPackageManager() PackageManagerName { - return Pip3 -} - -func (p *Pip3Extractor) Extract(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) { - return parseLockfile(lockfilePath, scanDir, p.GetEcosystem()) -} diff --git a/go.mod b/go.mod index de1c394..d7e62de 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,6 @@ require ( github.com/fatih/color v1.18.0 github.com/goccy/go-yaml v1.19.2 github.com/gofrs/flock v0.13.0 - github.com/google/osv-scalibr v0.2.1 github.com/google/uuid v1.6.0 github.com/jedib0t/go-pretty/v6 v6.7.9 github.com/landlock-lsm/go-landlock v0.7.0 @@ -28,6 +27,7 @@ require ( golang.org/x/sync v0.20.0 golang.org/x/sys v0.43.0 golang.org/x/term v0.42.0 + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 google.golang.org/grpc v1.81.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 @@ -36,7 +36,6 @@ require ( require ( al.essio.dev/pkg/shellescape v1.5.1 // indirect buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 // indirect - github.com/BurntSushi/toml v1.5.0 // indirect github.com/Masterminds/semver/v3 v3.3.1 // indirect github.com/caarlos0/env/v11 v11.3.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect @@ -47,14 +46,10 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect - github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect - github.com/go-git/go-billy/v5 v5.6.2 // indirect - github.com/go-git/go-git/v5 v5.14.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.2 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/gobwas/glob v0.2.3 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang/protobuf v1.5.4 // indirect @@ -63,7 +58,6 @@ require ( github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -73,21 +67,19 @@ require ( github.com/package-url/packageurl-go v0.1.3 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/tidwall/jsonc v0.3.2 // indirect github.com/zalando/go-keyring v0.2.6 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/text v0.35.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect - gopkg.in/warnings.v0 v0.1.2 // indirect kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 71f30c1..1753471 100644 --- a/go.sum +++ b/go.sum @@ -4,14 +4,10 @@ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-202604152011 buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= buf.build/gen/go/safedep/api/grpc/go v1.6.2-20260528074646-b9e182189444.1 h1:zpjFPeuPS4AdzfOMlwDSVWwxrRBrkL0ul0gV65RYzh8= buf.build/gen/go/safedep/api/grpc/go v1.6.2-20260528074646-b9e182189444.1/go.mod h1:8pVZh4owzo4YXcKvFvdWEYGr4k/1VHGR0h39XHsuHD4= -buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.11-20260620084912-77c7bb923ddb.1 h1:AYEqYqmDeF99lbHGJYjyzACLhJhwF9cJVcNCdl3vwYQ= -buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.11-20260620084912-77c7bb923ddb.1/go.mod h1:I8E+sZXJNqzWBtSlRGCoiEorLSRiix50h2R/66aBzME= buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.11-20260713161921-716fa3011a21.1 h1:k3kSCmCfcA8kJcI76f0Tj7JD9bx0U4EJuWvBrNO6JXY= buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.11-20260713161921-716fa3011a21.1/go.mod h1:I8E+sZXJNqzWBtSlRGCoiEorLSRiix50h2R/66aBzME= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= @@ -62,12 +58,6 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= -github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= -github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= -github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= -github.com/go-git/go-git/v5 v5.14.0 h1:/MD3lCrGjCen5WfEAzKg00MJJffKhC8gzS80ycmCi60= -github.com/go-git/go-git/v5 v5.14.0/go.mod h1:Z5Xhoia5PcWA3NF8vRLURn9E5FRhSl7dGj9ItW3Wk5k= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= @@ -85,8 +75,6 @@ github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCW github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= -github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= @@ -112,8 +100,6 @@ github.com/google/go-github/v74 v74.0.0 h1:yZcddTUn8DPbj11GxnMrNiAnXH14gNs559AsU github.com/google/go-github/v74 v74.0.0/go.mod h1:ubn/YdyftV80VPSI26nSJvaEsTOnsjrxG3o9kJhcyak= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= -github.com/google/osv-scalibr v0.2.1 h1:d1SpwzXfNiRafUMNpei3tQg8dDLSWe7JAPNEHxhzxDk= -github.com/google/osv-scalibr v0.2.1/go.mod h1:gTmbCPgh9ooYnU55N32qPxHgFubkxgiDxsoCAMQc2Nc= github.com/google/pprof v0.0.0-20250602020802-c6617b811d0e h1:FJta/0WsADCe1r9vQjdHbd3KuiLPu7Y9WlyLGwMUNyE= github.com/google/pprof v0.0.0-20250602020802-c6617b811d0e/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= @@ -126,8 +112,6 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= -github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jedib0t/go-pretty/v6 v6.7.9 h1:frarzQWmkZd97syT81+TH8INKPpzoxQnk+Mk5EIHSrM= github.com/jedib0t/go-pretty/v6 v6.7.9/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -161,8 +145,6 @@ github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwp github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -176,14 +158,6 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/safedep/dry v0.0.0-20260620130340-2af76e505537 h1:IiUlF9LzpoTUdV9RqsstiFRkPdP8fFQmCfbAHoNii0k= -github.com/safedep/dry v0.0.0-20260620130340-2af76e505537/go.mod h1:OUa+lopsqWFoDCzy3/DXqzi8JDl3g2q82JeskLq/Tpk= -github.com/safedep/dry v0.0.0-20260710083559-4501f2533064 h1:xNE9r8aJk/RxwPGK7wpT4Nm8cjLwO+vtGG8n6pfEhbk= -github.com/safedep/dry v0.0.0-20260710083559-4501f2533064/go.mod h1:WfJPfXgWWLfgi72PGllhHRUGoj7vh+zJ5cx4RJ4qRdM= -github.com/safedep/dry v0.0.0-20260710084513-c7378927978f h1:WeminE3k3HbGPhPPsJcBZP6bomTcg7tuESCCUzFl2Mc= -github.com/safedep/dry v0.0.0-20260710084513-c7378927978f/go.mod h1:WfJPfXgWWLfgi72PGllhHRUGoj7vh+zJ5cx4RJ4qRdM= -github.com/safedep/dry v0.0.0-20260710090004-346776184be5 h1:KDcSwAhNqLudyqn4iJEYacbAc1yf/g613BnpPTi7dm0= -github.com/safedep/dry v0.0.0-20260710090004-346776184be5/go.mod h1:WfJPfXgWWLfgi72PGllhHRUGoj7vh+zJ5cx4RJ4qRdM= github.com/safedep/dry v0.0.0-20260716095238-84cd2b3cd3a4 h1:9u5mj4LOteAm89eN0hg6eJ38kzc1ArfEjRiSXcKB2v8= github.com/safedep/dry v0.0.0-20260716095238-84cd2b3cd3a4/go.mod h1:OO3Tcxd+SBHRaN42jv2RjgwZWsUa8jFHB0mDMi5HWyY= github.com/safedep/ptyx v0.2.1-0.20260529140457-d1f745842a6a h1:oJu4dgmz/weiU3CMhFKiXd5zwgvPwPsm20MzG/uAt0s= @@ -218,8 +192,6 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/tidwall/jsonc v0.3.2 h1:ZTKrmejRlAJYdn0kcaFqRAKlxxFIC21pYq8vLa4p2Wc= -github.com/tidwall/jsonc v0.3.2/go.mod h1:dw+3CIxqHi+t8eFSpzzMlcVYxKp08UP5CD8/uSFCyJE= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= @@ -334,8 +306,6 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= -gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= -gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go.work.sum b/go.work.sum index 9fe44cb..6382581 100644 --- a/go.work.sum +++ b/go.work.sum @@ -163,6 +163,7 @@ github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/michaelkedar/xml v0.0.0-20250310223042-5d14c9302b17/go.mod h1:KUAB0Nhc2O/lzyPLuWF6Jm/HVC4GIRHWpxTWpy14WHM= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= @@ -196,6 +197,7 @@ github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0V github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= diff --git a/guard/guard.go b/guard/guard.go deleted file mode 100644 index 34fa9e7..0000000 --- a/guard/guard.go +++ /dev/null @@ -1,525 +0,0 @@ -package guard - -import ( - "context" - "fmt" - "io" - "os" - "slices" - "sync" - "time" - - packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" - "github.com/safedep/dry/log" - "github.com/safedep/pmg/analyzer" - "github.com/safedep/pmg/config" - "github.com/safedep/pmg/extractor" - "github.com/safedep/pmg/internal/audit" - "github.com/safedep/pmg/internal/ui" - "github.com/safedep/pmg/packagemanager" -) - -// CommandExecutor executes a parsed package manager command directly. -// It is injected into the guard so that callers control execution behavior -// (e.g., dry-run, sandbox application) without guard depending on internal packages. -type CommandExecutor func(ctx context.Context, pc *packagemanager.ParsedCommand) error - -type PackageManagerGuardInteraction struct { - // SetStatus is called to set the status of the guard in the UI - SetStatus func(status string) - - // ClearStatus is called to clear the status of the guard in the UI - ClearStatus func() - - // ShowWarning is called to show a warning message to the user - ShowWarning func(message string) - - // GetConfirmationOnMalware is called to get the confirmation of the user on the malware packages - GetConfirmationOnMalware func(malwarePackages []*analyzer.PackageVersionAnalysisResult) (bool, error) - - // Block is called to block the installation of the malware packages. One or more malicious - // packages are passed as arguments. These are the packages that were detected as malicious. - // Client code must perform the necessary error handling and termination of the process. - Block func(config *ui.BlockConfig) error - - // inputReader is the reader to use for user input during confirmations. - // If nil, os.Stdin is used. This is set via SetInput to allow PTY input routing. - inputReader io.Reader -} - -// SetInput sets the input reader for user confirmations. -// This allows the PTY switchboard to route input to the prompt during confirmations. -func (i *PackageManagerGuardInteraction) SetInput(r io.Reader) { - i.inputReader = r -} - -// Reader returns the configured input reader, or os.Stdin if none is set. -func (i *PackageManagerGuardInteraction) Reader() io.Reader { - if i.inputReader != nil { - return i.inputReader - } - return os.Stdin -} - -type PackageManagerGuardConfig struct { - ResolveDependencies bool - MaxConcurrentAnalyzes int - AnalysisTimeout time.Duration - DryRun bool - InsecureInstallation bool -} - -func DefaultPackageManagerGuardConfig() PackageManagerGuardConfig { - return PackageManagerGuardConfig{ - ResolveDependencies: true, - MaxConcurrentAnalyzes: 10, - AnalysisTimeout: 5 * time.Minute, - DryRun: false, - InsecureInstallation: false, - } -} - -// GuardResult captures execution statistics from the guard for reporting. -// It contains pure data - the calling flow is responsible for interpreting -// the outcome based on this data. -type GuardResult struct { - TotalAnalyzed int - TrustedSkipped int - AllowedCount int - ConfirmedCount int - BlockedCount int - BlockedPackages []*analyzer.PackageVersionAnalysisResult - ConfirmedPackages []*analyzer.PackageVersionAnalysisResult - // WasUserCancelled is true if the user declined to install suspicious packages - WasUserCancelled bool -} - -type packageManagerGuard struct { - config PackageManagerGuardConfig - interaction PackageManagerGuardInteraction - analyzers []analyzer.PackageVersionAnalyzer - packageManager packagemanager.PackageManager - packageResolver packagemanager.PackageResolver - executor CommandExecutor -} - -func NewPackageManagerGuard(config PackageManagerGuardConfig, - packageManager packagemanager.PackageManager, - packageResolver packagemanager.PackageResolver, - analyzers []analyzer.PackageVersionAnalyzer, - interaction PackageManagerGuardInteraction, - executor CommandExecutor, -) (*packageManagerGuard, error) { - return &packageManagerGuard{ - interaction: interaction, - analyzers: analyzers, - packageManager: packageManager, - packageResolver: packageResolver, - config: config, - executor: executor, - }, nil -} - -func (g *packageManagerGuard) Run(ctx context.Context, args []string, parsedCommand *packagemanager.ParsedCommand) (*GuardResult, error) { - log.Debugf("Running package manager guard with args: %v", args) - - result := &GuardResult{} - - // Log the installation start - if g.packageManager != nil { - audit.LogInstallStarted(g.packageManager.Name(), args) - } - - if g.config.InsecureInstallation { - log.Debugf("Bypassing block for unconfirmed malicious packages due to PMG_INSECURE_INSTALLATION") - g.showWarning("INSECURE INSTALLATION MODE - Malware protection bypassed!") - return result, g.continueExecution(ctx, parsedCommand) - } - - if !parsedCommand.HasInstallTarget() { - // Check if this is a manifest-based installation - if parsedCommand.ShouldExtractFromManifest() { - log.Debugf("Detected manifest-based installation, extracting packages from manifest files") - return g.handleManifestInstallation(ctx, parsedCommand) - } - - log.Debugf("No install target found, continuing execution") - return result, g.continueExecution(ctx, parsedCommand) - } - - blockConfig := ui.NewDefaultBlockConfig() - - // TODO: We should track the dependency tree here so that we can trace a - // dependency to one of the parent packages from install targets - - packagesToAnalyze := []*packagev1.PackageVersion{} - for _, installTarget := range parsedCommand.InstallTargets { - packagesToAnalyze = append(packagesToAnalyze, installTarget.PackageVersion) - } - - log.Debugf("Found %d install targets", len(parsedCommand.InstallTargets)) - - g.setStatus(fmt.Sprintf("Resolving dependencies for %d package(s)", len(parsedCommand.InstallTargets))) - - if g.config.ResolveDependencies { - for _, pkg := range parsedCommand.InstallTargets { - if pkg.PackageVersion.GetVersion() == "" { - log.Debugf("Resolving latest version for package: %s", pkg.PackageVersion.Package.Name) - latestVersion, err := g.packageResolver.ResolveLatestVersion(ctx, pkg.PackageVersion.GetPackage()) - if err != nil { - return result, fmt.Errorf("failed to resolve latest version: %w", err) - } - - pkg.PackageVersion.Version = latestVersion.GetVersion() - } - - log.Debugf("Resolving dependencies for package: %s@%s", pkg.PackageVersion.Package.Name, pkg.PackageVersion.Version) - - dependencies, err := g.packageResolver.ResolveDependencies(ctx, pkg.PackageVersion) - if err != nil { - return result, fmt.Errorf("failed to resolve dependencies: %w", err) - } - - log.Debugf("Resolved %d dependencies for package: %s@%s", len(dependencies), - pkg.PackageVersion.Package.Name, pkg.PackageVersion.Version) - - packagesToAnalyze = append(packagesToAnalyze, dependencies...) - } - } - - log.Debugf("Checking %d packages for malware", len(packagesToAnalyze)) - - g.setStatus(fmt.Sprintf("Analyzing %d dependencies for malware", len(packagesToAnalyze))) - - analysisResults, trustedSkipped, err := g.concurrentAnalyzePackages(ctx, packagesToAnalyze) - if err != nil { - return result, fmt.Errorf("failed to analyze packages: %w", err) - } - - // Populate result statistics - result.TotalAnalyzed = len(packagesToAnalyze) - result.TrustedSkipped = trustedSkipped - - confirmableMalwarePackages := []*analyzer.PackageVersionAnalysisResult{} - for _, analysisResult := range analysisResults { - if analysisResult.Action == analyzer.ActionBlock { - result.BlockedCount++ - result.BlockedPackages = append(result.BlockedPackages, analysisResult) - blockConfig.MalwarePackages = append(blockConfig.MalwarePackages, analysisResult) - g.logMalwareDetection(analysisResult, true) - - return result, nil - } - - if analysisResult.Action == analyzer.ActionConfirm { - confirmableMalwarePackages = append(confirmableMalwarePackages, analysisResult) - } else { - result.AllowedCount++ - g.warnIfExcluded(analysisResult) - } - } - - if len(confirmableMalwarePackages) > 0 { - confirmed, err := g.getConfirmationOnMalware(ctx, confirmableMalwarePackages) - if err != nil { - return result, fmt.Errorf("failed to get confirmation on malware: %w", err) - } - - if !confirmed { - blockConfig.ShowReference = false - blockConfig.MalwarePackages = confirmableMalwarePackages - for _, pkg := range confirmableMalwarePackages { - g.logMalwareDetection(pkg, true) - result.BlockedCount++ - result.BlockedPackages = append(result.BlockedPackages, pkg) - } - result.WasUserCancelled = true - - return result, nil - } - - // User confirmed installation despite warning - for _, pkg := range confirmableMalwarePackages { - g.logMalwareDetection(pkg, false) - result.ConfirmedCount++ - result.ConfirmedPackages = append(result.ConfirmedPackages, pkg) - } - } - - log.Debugf("No malicious packages found, continuing execution") - - // Log successful installation allowance - if len(parsedCommand.InstallTargets) > 0 { - for _, target := range parsedCommand.InstallTargets { - audit.LogInstallAllowed(target.PackageVersion, len(packagesToAnalyze)) - } - } - - g.clearStatus() - return result, g.continueExecution(ctx, parsedCommand) -} - -func (g *packageManagerGuard) continueExecution(ctx context.Context, pc *packagemanager.ParsedCommand) error { - return g.executor(ctx, pc) -} - -func (g *packageManagerGuard) concurrentAnalyzePackages(ctx context.Context, - packages []*packagev1.PackageVersion) ([]*analyzer.PackageVersionAnalysisResult, int, error) { - ctx, cancel := context.WithTimeout(ctx, g.config.AnalysisTimeout) - defer cancel() - - wg := sync.WaitGroup{} - jobs := make(chan *packagev1.PackageVersion, len(packages)) - results := make(chan *analyzer.PackageVersionAnalysisResult, len(packages)) - - for i := 0; i < g.config.MaxConcurrentAnalyzes; i++ { - wg.Add(1) - go func() { - defer wg.Done() - for pkg := range jobs { - for _, analyzer := range g.analyzers { - analysisResult, err := analyzer.Analyze(ctx, pkg) - if err != nil { - // This is not an error because we may not have results for all packages - log.Debugf("failed to analyze package: %v", err) - continue - } - - results <- analysisResult - } - } - }() - } - - // Queue all packages for analysis, tracking trusted packages skipped - trustedSkipped := 0 - for _, pkg := range packages { - if config.IsTrustedPackage(pkg) { - log.Debugf("Skipping trusted package: %s/%s@%s", - pkg.GetPackage().GetEcosystem(), pkg.GetPackage().GetName(), pkg.GetVersion()) - trustedSkipped++ - continue - } - - jobs <- pkg - } - - close(jobs) - - analysisResults := []*analyzer.PackageVersionAnalysisResult{} - - // We must wait for the results go routine to collect all results - rwg := sync.WaitGroup{} - rwg.Add(1) - go func() { - defer rwg.Done() - for result := range results { - analysisResults = append(analysisResults, result) - } - }() - - waiter := make(chan struct{}) - go func() { - wg.Wait() - close(results) - - rwg.Wait() - close(waiter) - }() - - select { - case <-waiter: - case <-ctx.Done(): - return nil, 0, fmt.Errorf("analysis timed out") - } - - return analysisResults, trustedSkipped, nil -} - -func (g *packageManagerGuard) getConfirmationOnMalware(ctx context.Context, malwarePackages []*analyzer.PackageVersionAnalysisResult) (bool, error) { - if g.interaction.GetConfirmationOnMalware == nil { - return false, nil - } - - return g.interaction.GetConfirmationOnMalware(malwarePackages) -} - -func (g *packageManagerGuard) setStatus(status string) { - if g.interaction.SetStatus == nil { - return - } - - g.interaction.SetStatus(status) -} - -func (g *packageManagerGuard) showWarning(message string) { - if g.interaction.ShowWarning == nil { - return - } - - g.interaction.ShowWarning(message) -} - -func (g *packageManagerGuard) clearStatus() { - if g.interaction.ClearStatus == nil { - return - } - - g.interaction.ClearStatus() -} - -func (g *packageManagerGuard) handleManifestInstallation(ctx context.Context, parsedCommand *packagemanager.ParsedCommand) (*GuardResult, error) { - result := &GuardResult{} - - extractorConfig := extractor.NewDefaultExtractorConfig() - extractorConfig.ExtractorPackageManager = extractor.PackageManagerName(g.packageManager.Name()) - extractorConfig.ManifestFiles = parsedCommand.ManifestFiles - - packageExtractor := extractor.New(*extractorConfig) - - packages, err := packageExtractor.ExtractManifest() - if err != nil { - return result, fmt.Errorf("failed to extract packages from manifest files: %w", err) - } - - blockConfig := ui.NewDefaultBlockConfig() - - if len(packages) == 0 { - log.Debugf("No packages found in manifest files, continuing execution") - return result, g.continueExecution(ctx, parsedCommand) - } - - log.Debugf("Extracted %d packages from manifest files", len(packages)) - - packagesToAnalyze := []*packagev1.PackageVersion{} - - // Add all packages to analyze that are extracted from manifest files - packagesToAnalyze = append(packagesToAnalyze, packages...) - - // Only resolve dependencies for requirements.txt because other lockfiles dependencies are already resolved - if g.config.ResolveDependencies && slices.Contains(parsedCommand.ManifestFiles, "requirements.txt") { - g.setStatus(fmt.Sprintf("Resolving dependencies for %d package(s)", len(packages))) - - for _, pkg := range packages { - if pkg.GetVersion() == "" { - log.Debugf("Resolving latest version for package: %s", pkg.Package.Name) - latestVersion, err := g.packageResolver.ResolveLatestVersion(ctx, pkg.GetPackage()) - if err != nil { - return result, fmt.Errorf("failed to resolve latest version: %w", err) - } - - pkg.Version = latestVersion.GetVersion() - } - - log.Debugf("Resolving dependencies for package: %s@%s", pkg.Package.Name, pkg.Version) - - dependencies, err := g.packageResolver.ResolveDependencies(ctx, pkg) - if err != nil { - return result, fmt.Errorf("failed to resolve dependencies: %w", err) - } - - log.Debugf("Resolved %d dependencies for package: %s@%s", len(dependencies), - pkg.Package.Name, pkg.Version) - - packagesToAnalyze = append(packagesToAnalyze, dependencies...) - } - } - - log.Debugf("Checking %d packages for malware", len(packagesToAnalyze)) - - g.setStatus(fmt.Sprintf("Analyzing %d dependencies from manifest files", len(packagesToAnalyze))) - - analysisResults, trustedSkipped, err := g.concurrentAnalyzePackages(ctx, packagesToAnalyze) - if err != nil { - return result, fmt.Errorf("failed to analyze packages: %w", err) - } - - // Populate result statistics - result.TotalAnalyzed = len(packagesToAnalyze) - result.TrustedSkipped = trustedSkipped - - confirmableMalwarePackages := []*analyzer.PackageVersionAnalysisResult{} - for _, analysisResult := range analysisResults { - if analysisResult.Action == analyzer.ActionBlock { - result.BlockedCount++ - result.BlockedPackages = append(result.BlockedPackages, analysisResult) - blockConfig.MalwarePackages = append(blockConfig.MalwarePackages, analysisResult) - - g.logMalwareDetection(analysisResult, true) - - return result, nil - } - - if analysisResult.Action == analyzer.ActionConfirm { - confirmableMalwarePackages = append(confirmableMalwarePackages, analysisResult) - } else { - result.AllowedCount++ - g.warnIfExcluded(analysisResult) - } - } - - if len(confirmableMalwarePackages) > 0 { - confirmed, err := g.getConfirmationOnMalware(ctx, confirmableMalwarePackages) - if err != nil { - return result, fmt.Errorf("failed to get confirmation on malware: %w", err) - } - - if !confirmed { - blockConfig.ShowReference = false - blockConfig.MalwarePackages = confirmableMalwarePackages - - for _, pkg := range confirmableMalwarePackages { - g.logMalwareDetection(pkg, true) - - result.BlockedCount++ - result.BlockedPackages = append(result.BlockedPackages, pkg) - } - - result.WasUserCancelled = true - - return result, nil - } - - // User confirmed installation despite warning - for _, pkg := range confirmableMalwarePackages { - g.logMalwareDetection(pkg, false) - result.ConfirmedCount++ - result.ConfirmedPackages = append(result.ConfirmedPackages, pkg) - } - } - - log.Debugf("No malicious packages found in manifest files, continuing execution") - - // Log successful installation allowance for manifest-based installations - if len(packages) > 0 { - audit.LogInstallAllowed(packages[0], len(packagesToAnalyze)) - } - - g.clearStatus() - return result, g.continueExecution(ctx, parsedCommand) -} - -// warnIfExcluded surfaces a security-relevant notice when a flagged package was -// allowed only because of a tenant-specific exclusion, so it is never silently -// trusted. -func (g *packageManagerGuard) warnIfExcluded(result *analyzer.PackageVersionAnalysisResult) { - if result == nil || !result.IsExcluded || result.PackageVersion == nil { - return - } - - pkg := result.PackageVersion - g.showWarning(fmt.Sprintf("Allowing flagged package %s@%s due to tenant exclusion (%s)", - pkg.GetPackage().GetName(), pkg.GetVersion(), result.ExclusionReason)) -} - -func (g *packageManagerGuard) logMalwareDetection(result *analyzer.PackageVersionAnalysisResult, blocked bool) { - if result == nil || result.PackageVersion == nil { - return - } - - if blocked { - audit.LogMalwareBlocked(result.PackageVersion, result.Summary, result.AnalysisID, result.ReferenceURL, result.IsMalware, result.IsVerified) - } else { - audit.LogMalwareConfirmed(result.PackageVersion, result.AnalysisID, result.IsMalware, result.IsVerified) - } -} diff --git a/guard/guard_test.go b/guard/guard_test.go deleted file mode 100644 index 1b8ec50..0000000 --- a/guard/guard_test.go +++ /dev/null @@ -1,265 +0,0 @@ -package guard - -import ( - "context" - "testing" - - packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" - "github.com/safedep/pmg/analyzer" - "github.com/safedep/pmg/internal/ui" - "github.com/safedep/pmg/packagemanager" - "github.com/stretchr/testify/assert" -) - -// noopExecutor is a no-op executor for use in tests that set DryRun=true -// or otherwise don't reach actual command execution. -var noopExecutor CommandExecutor = func(_ context.Context, _ *packagemanager.ParsedCommand) error { - return nil -} - -func TestGuardConcurrentlyAnalyzePackagesMalwareQueryService(t *testing.T) { - mq, err := analyzer.NewMalysisQueryAnalyzer(analyzer.MalysisQueryAnalyzerConfig{}) - if err != nil { - t.Fatalf("failed to create mq: %v", err) - } - - pg, err := NewPackageManagerGuard(DefaultPackageManagerGuardConfig(), nil, nil, - []analyzer.PackageVersionAnalyzer{mq}, PackageManagerGuardInteraction{ - ShowWarning: func(message string) {}, - }, noopExecutor) - if err != nil { - t.Fatalf("failed to create pg: %v", err) - } - - t.Run("should resolve a single known malicious package version", func(t *testing.T) { - r, trustedSkipped, err := pg.concurrentAnalyzePackages(context.Background(), []*packagev1.PackageVersion{ - { - Package: &packagev1.Package{ - Name: "nyc-config", - Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM, - }, - Version: "10.0.0", - }, - }) - if err != nil { - t.Fatalf("failed to analyze packages: %v", err) - } - - assert.Equal(t, 0, trustedSkipped) - assert.Equal(t, 1, len(r)) - assert.Equal(t, "nyc-config", r[0].PackageVersion.GetPackage().GetName()) - assert.Equal(t, "10.0.0", r[0].PackageVersion.GetVersion()) - assert.Equal(t, packagev1.Ecosystem_ECOSYSTEM_NPM, r[0].PackageVersion.GetPackage().GetEcosystem()) - assert.NotEmpty(t, r[0].ReferenceURL) - assert.NotEmpty(t, r[0].Summary) - assert.NotNil(t, r[0].Data) - assert.Equal(t, analyzer.ActionBlock, r[0].Action) - }) -} - -func TestGuardInsecureInstallation(t *testing.T) { - mq, err := analyzer.NewMalysisQueryAnalyzer(analyzer.MalysisQueryAnalyzerConfig{}) - if err != nil { - t.Fatalf("failed to create mq: %v", err) - } - - t.Run("should bypass malware blocking when InsecureInstallation is enabled", func(t *testing.T) { - // Create guard with InsecureInstallation enabled - config := DefaultPackageManagerGuardConfig() - config.InsecureInstallation = true - config.DryRun = true // Enable dry run to avoid actual command execution - config.ResolveDependencies = false // Disable dependency resolution to avoid nil pointer issues - - blockCalled := false - warningCalled := false - var warningMessage string - - interaction := PackageManagerGuardInteraction{ - ShowWarning: func(message string) { - warningCalled = true - warningMessage = message - }, - Block: func(config *ui.BlockConfig) error { - blockCalled = true - return nil - }, - } - - pg, err := NewPackageManagerGuard(config, nil, nil, - []analyzer.PackageVersionAnalyzer{mq}, interaction, noopExecutor) - if err != nil { - t.Fatalf("failed to create pg: %v", err) - } - - // Create a parsed command with a known malicious package - parsedCommand := &packagemanager.ParsedCommand{ - Command: packagemanager.Command{ - Exe: "npm", - Args: []string{"install", "nyc-config@10.0.0"}, - }, - InstallTargets: []*packagemanager.PackageInstallTarget{ - { - PackageVersion: &packagev1.PackageVersion{ - Package: &packagev1.Package{ - Name: "nyc-config", - Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM, - }, - Version: "10.0.0", - }, - }, - }, - } - - _, err = pg.Run(context.Background(), []string{"npm", "install", "nyc-config@10.0.0"}, parsedCommand) - - // With dry run enabled, we expect no error even though we're bypassing execution - assert.NoError(t, err) - - // Block should not be called because InsecureInstallation bypasses the analysis - assert.False(t, blockCalled, "Block should not be called when InsecureInstallation is enabled") - - // Warning should be called to inform user about insecure installation - assert.True(t, warningCalled, "Warning should be called when InsecureInstallation is enabled") - assert.Contains(t, warningMessage, "INSECURE INSTALLATION MODE", "Warning message should mention insecure installation") - }) - - t.Run("should block malware when InsecureInstallation is disabled", func(t *testing.T) { - // Create guard with InsecureInstallation disabled (default) - config := DefaultPackageManagerGuardConfig() - config.InsecureInstallation = false - config.DryRun = true - config.ResolveDependencies = false // Disable dependency resolution to avoid nil pointer issues - - interaction := PackageManagerGuardInteraction{ - ShowWarning: func(message string) {}, - } - - pg, err := NewPackageManagerGuard(config, nil, nil, - []analyzer.PackageVersionAnalyzer{mq}, interaction, noopExecutor) - if err != nil { - t.Fatalf("failed to create pg: %v", err) - } - - // Create a parsed command with a known malicious package - parsedCommand := &packagemanager.ParsedCommand{ - Command: packagemanager.Command{ - Exe: "npm", - Args: []string{"install", "nyc-config@10.0.0"}, - }, - InstallTargets: []*packagemanager.PackageInstallTarget{ - { - PackageVersion: &packagev1.PackageVersion{ - Package: &packagev1.Package{ - Name: "nyc-config", - Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM, - }, - Version: "10.0.0", - }, - }, - }, - } - - r, err := pg.Run(context.Background(), []string{"npm", "install", "nyc-config@10.0.0"}, parsedCommand) - - // We expect no error from the guard itself (blocking is handled via the Block callback) - assert.NoError(t, err) - - // Verify that the malicious package was detected and blocked - assert.NotEmpty(t, r.BlockedPackages, "Blocked packages should not be empty") - assert.Greater(t, r.BlockedCount, 0) - assert.Equal(t, "nyc-config", r.BlockedPackages[0].PackageVersion.GetPackage().GetName()) - assert.Equal(t, "10.0.0", r.BlockedPackages[0].PackageVersion.GetVersion()) - assert.Equal(t, analyzer.ActionBlock, r.BlockedPackages[0].Action) - }) - - t.Run("should continue execution for commands without install targets when InsecureInstallation is enabled", func(t *testing.T) { - // Create guard with InsecureInstallation enabled - config := DefaultPackageManagerGuardConfig() - config.InsecureInstallation = true - config.DryRun = true - config.ResolveDependencies = false // Disable dependency resolution to avoid nil pointer issues - - blockCalled := false - - interaction := PackageManagerGuardInteraction{ - ShowWarning: func(message string) {}, - Block: func(config *ui.BlockConfig) error { - blockCalled = true - return nil - }, - } - - pg, err := NewPackageManagerGuard(config, nil, nil, - []analyzer.PackageVersionAnalyzer{mq}, interaction, noopExecutor) - if err != nil { - t.Fatalf("failed to create pg: %v", err) - } - - // Create a parsed command without install targets (e.g., npm list) - parsedCommand := &packagemanager.ParsedCommand{ - Command: packagemanager.Command{ - Exe: "npm", - Args: []string{"list"}, - }, - InstallTargets: []*packagemanager.PackageInstallTarget{}, // No install targets - } - - _, err = pg.Run(context.Background(), []string{"npm", "list"}, parsedCommand) - - // Should not error since there are no install targets to analyze - assert.NoError(t, err) - - // Block should not be called since there are no packages to analyze - assert.False(t, blockCalled, "Block should not be called when there are no install targets") - }) - - t.Run("should handle manifest-based installation when InsecureInstallation is enabled", func(t *testing.T) { - // Create guard with InsecureInstallation enabled - config := DefaultPackageManagerGuardConfig() - config.InsecureInstallation = true - config.DryRun = true - config.ResolveDependencies = false // Disable dependency resolution to avoid nil pointer issues - - blockCalled := false - - interaction := PackageManagerGuardInteraction{ - ShowWarning: func(message string) {}, - Block: func(config *ui.BlockConfig) error { - blockCalled = true - return nil - }, - } - - pg, err := NewPackageManagerGuard(config, nil, nil, - []analyzer.PackageVersionAnalyzer{mq}, interaction, noopExecutor) - if err != nil { - t.Fatalf("failed to create pg: %v", err) - } - - // Create a parsed command for manifest-based installation - parsedCommand := &packagemanager.ParsedCommand{ - Command: packagemanager.Command{ - Exe: "npm", - Args: []string{"install"}, - }, - InstallTargets: []*packagemanager.PackageInstallTarget{}, // No direct install targets - IsManifestInstall: true, - ManifestFiles: []string{"package.json"}, - } - - _, err = pg.Run(context.Background(), []string{"npm", "install"}, parsedCommand) - - // Should not error and should bypass malware checking - assert.NoError(t, err) - - // Block should not be called because InsecureInstallation bypasses analysis - assert.False(t, blockCalled, "Block should not be called when InsecureInstallation is enabled for manifest installation") - }) - - t.Run("should verify InsecureInstallation defaults to false", func(t *testing.T) { - config := DefaultPackageManagerGuardConfig() - - // Verify that InsecureInstallation defaults to false - assert.False(t, config.InsecureInstallation, "InsecureInstallation should default to false") - }) -} diff --git a/internal/audit/audit.go b/internal/audit/audit.go index f635dd4..ee8af4b 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -290,7 +290,6 @@ func LogSessionComplete(outcome Outcome, flowType FlowType) { Duration: time.Since(s.startTime), SandboxEnabled: cfg.Config.Sandbox.Enabled, ParanoidMode: cfg.Config.Paranoid, - TransitiveEnabled: cfg.Config.Transitive, }) } diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go index ec392f1..96522f4 100644 --- a/internal/audit/audit_test.go +++ b/internal/audit/audit_test.go @@ -255,14 +255,14 @@ func TestLogSessionCompleteDispatchesEvent(t *testing.T) { a.startSession("npm", []string{"install", "express"}) LogInstallAllowed(testPackageVersion("express", "4.0.0", "npm"), 1) - LogSessionComplete(OutcomeSuccess, FlowTypeGuard) + LogSessionComplete(OutcomeSuccess, FlowTypeProxy) events := s.getEvents() require.Len(t, events, 2) assert.Equal(t, EventTypeSessionComplete, events[1].Type) require.NotNil(t, events[1].SessionData) assert.Equal(t, "npm", events[1].SessionData.PackageManager) - assert.Equal(t, FlowTypeGuard, events[1].SessionData.FlowType) + assert.Equal(t, FlowTypeProxy, events[1].SessionData.FlowType) assert.Equal(t, OutcomeSuccess, events[1].SessionData.Outcome) assert.Equal(t, uint32(1), events[1].SessionData.AllowedCount) } @@ -270,7 +270,7 @@ func TestLogSessionCompleteDispatchesEvent(t *testing.T) { func TestLogSessionCompleteSilentWhenNotInitialized(t *testing.T) { resetGlobal() // Should not panic - LogSessionComplete(OutcomeSuccess, FlowTypeGuard) + LogSessionComplete(OutcomeSuccess, FlowTypeProxy) } func TestLogSessionSummaryDispatchesEvent(t *testing.T) { diff --git a/internal/audit/cloud_sink_test.go b/internal/audit/cloud_sink_test.go index 55b4416..635e71b 100644 --- a/internal/audit/cloud_sink_test.go +++ b/internal/audit/cloud_sink_test.go @@ -134,7 +134,7 @@ func TestCloudSinkSetsInvocationContextOnSessionComplete(t *testing.T) { Timestamp: time.Now(), SessionData: &SessionData{ PackageManager: "npm", - FlowType: FlowTypeGuard, + FlowType: FlowTypeProxy, Outcome: OutcomeSuccess, TotalAnalyzed: 1, AllowedCount: 1, diff --git a/internal/audit/cloud_translate.go b/internal/audit/cloud_translate.go index c5dd4d8..871bed9 100644 --- a/internal/audit/cloud_translate.go +++ b/internal/audit/cloud_translate.go @@ -137,7 +137,6 @@ func newSessionSummaryEvent(data *SessionData) *controltowerv1.PmgEvent { summary.SetDuration(durationpb.New(data.Duration)) summary.SetSandboxEnabled(data.SandboxEnabled) summary.SetParanoidMode(data.ParanoidMode) - summary.SetTransitiveEnabled(data.TransitiveEnabled) summary.SetOutcome(mapSessionOutcome(data.Outcome)) e := &controltowerv1.PmgEvent{} @@ -159,11 +158,11 @@ func newInsecureBypassFromSession(data *SessionData) *controltowerv1.PmgEvent { func mapFlowType(ft FlowType) controltowerv1.PmgFlowType { switch ft { - case FlowTypeGuard: - return controltowerv1.PmgFlowType_PMG_FLOW_TYPE_GUARD case FlowTypeProxy: return controltowerv1.PmgFlowType_PMG_FLOW_TYPE_PROXY default: + // Includes "guard" events recorded by pre-removal PMG versions that may + // still be present in an unsynced local WAL. return controltowerv1.PmgFlowType_PMG_FLOW_TYPE_UNSPECIFIED } } diff --git a/internal/audit/cloud_translate_test.go b/internal/audit/cloud_translate_test.go index 536958d..025c841 100644 --- a/internal/audit/cloud_translate_test.go +++ b/internal/audit/cloud_translate_test.go @@ -278,7 +278,6 @@ func TestTranslateSessionComplete(t *testing.T) { Duration: 5 * time.Second, SandboxEnabled: true, ParanoidMode: false, - TransitiveEnabled: true, }, } @@ -297,7 +296,6 @@ func TestTranslateSessionComplete(t *testing.T) { assert.Equal(t, uint32(3), summary.GetCooldownBlockedCount()) assert.True(t, summary.GetSandboxEnabled()) assert.False(t, summary.GetParanoidMode()) - assert.True(t, summary.GetTransitiveEnabled()) assert.Equal(t, controltowerv1.PmgSessionOutcome_PMG_SESSION_OUTCOME_SUCCESS, summary.GetOutcome()) } @@ -306,7 +304,7 @@ func TestTranslateSessionCompleteWithInsecureBypass(t *testing.T) { Type: EventTypeSessionComplete, SessionData: &SessionData{ PackageManager: "pip", - FlowType: FlowTypeGuard, + FlowType: FlowTypeProxy, Outcome: OutcomeInsecureBypass, InsecureBypassed: 3, }, @@ -340,8 +338,8 @@ func TestMapFlowType(t *testing.T) { input FlowType expected controltowerv1.PmgFlowType }{ - {"guard", FlowTypeGuard, controltowerv1.PmgFlowType_PMG_FLOW_TYPE_GUARD}, {"proxy", FlowTypeProxy, controltowerv1.PmgFlowType_PMG_FLOW_TYPE_PROXY}, + {"legacy guard maps to unspecified", FlowType("guard"), controltowerv1.PmgFlowType_PMG_FLOW_TYPE_UNSPECIFIED}, {"unknown", FlowType("other"), controltowerv1.PmgFlowType_PMG_FLOW_TYPE_UNSPECIFIED}, {"empty", FlowType(""), controltowerv1.PmgFlowType_PMG_FLOW_TYPE_UNSPECIFIED}, } diff --git a/internal/audit/event.go b/internal/audit/event.go index 437d37b..6874563 100644 --- a/internal/audit/event.go +++ b/internal/audit/event.go @@ -21,14 +21,12 @@ type SessionData struct { Duration time.Duration SandboxEnabled bool ParanoidMode bool - TransitiveEnabled bool } // FlowType identifies how PMG intercepted the package installation. type FlowType string const ( - FlowTypeGuard FlowType = "guard" FlowTypeProxy FlowType = "proxy" ) diff --git a/internal/flows/common_flow.go b/internal/flows/common_flow.go deleted file mode 100644 index 0660c41..0000000 --- a/internal/flows/common_flow.go +++ /dev/null @@ -1,137 +0,0 @@ -package flows - -import ( - "context" - "fmt" - "os" - "time" - - "github.com/safedep/pmg/analyzer" - "github.com/safedep/pmg/config" - "github.com/safedep/pmg/guard" - "github.com/safedep/pmg/internal/audit" - "github.com/safedep/pmg/internal/runner" - "github.com/safedep/pmg/internal/ui" - "github.com/safedep/pmg/packagemanager" -) - -type commonFlow struct { - pm packagemanager.PackageManager - packageResolver packagemanager.PackageResolver -} - -// Creates a common flow of execution for all package managers. This should work for most -// of the cases unless a package manager has its own unique requirements. Configuration -// should be passed through the context (Global Config) -func Common(pm packagemanager.PackageManager, pkgResolver packagemanager.PackageResolver) *commonFlow { - return &commonFlow{ - pm: pm, - packageResolver: pkgResolver, - } -} - -func (f *commonFlow) Run(ctx context.Context, args []string, parsedCmd *packagemanager.ParsedCommand) error { - var analyzers []analyzer.PackageVersionAnalyzer - - // Configure sandbox based on command type and enforcement policy - config.ConfigureSandbox(parsedCmd.IsInstallationCommand() || parsedCmd.MayDownloadPackages()) - - cfg := config.Get() - - // Initialize report data at the start - reportData := ui.NewReportData() - reportData.PackageManagerName = f.pm.Name() - reportData.FlowType = ui.FlowTypeGuard - reportData.DryRun = cfg.DryRun - reportData.InsecureMode = cfg.InsecureInstallation - reportData.TransitiveEnabled = cfg.Config.Transitive - reportData.ParanoidMode = cfg.Config.Paranoid - reportData.SandboxEnabled = cfg.Config.Sandbox.Enabled - - if cfg.Config.Sandbox.Enabled { - if policyRef, exists := cfg.Config.Sandbox.PolicyFor(f.pm.Name()); exists { - reportData.SandboxProfile = policyRef.Profile - } - } - if cfg.SandboxProfileOverride != "" { - reportData.SandboxProfile = cfg.SandboxProfileOverride - } - - startTime := time.Now() - - malysisQueryAnalyzer, err := analyzer.NewMalysisAnalyzer(analyzer.MalysisQueryAnalyzerConfig{}) - if err != nil { - return fmt.Errorf("failed to create malware analyzer: %w", err) - } - - analyzers = append(analyzers, malysisQueryAnalyzer) - - interaction := guard.PackageManagerGuardInteraction{ - SetStatus: ui.SetStatus, - ClearStatus: ui.ClearStatus, - ShowWarning: ui.ShowWarning, - GetConfirmationOnMalware: ui.GetConfirmationOnMalware, - Block: ui.BlockNoExit, - } - - guardConfig := guard.DefaultPackageManagerGuardConfig() - guardConfig.DryRun = cfg.DryRun - guardConfig.InsecureInstallation = cfg.InsecureInstallation - - pmName := f.pm.Name() - executor := func(ctx context.Context, pc *packagemanager.ParsedCommand) error { - return runner.Execute(ctx, pc, pmName, cfg.DryRun) - } - - guardManager, err := guard.NewPackageManagerGuard(guardConfig, f.pm, f.packageResolver, analyzers, interaction, executor) - if err != nil { - return fmt.Errorf("failed to create package manager guard: %s", err) - } - - guardResult, err := guardManager.Run(ctx, args, parsedCmd) - - // Populate report data from guard result - reportData.StartTime = startTime - if guardResult != nil { - reportData.TotalAnalyzed = guardResult.TotalAnalyzed - reportData.TrustedSkipped = guardResult.TrustedSkipped - reportData.AllowedCount = guardResult.AllowedCount - reportData.ConfirmedCount = guardResult.ConfirmedCount - reportData.BlockedCount = guardResult.BlockedCount - reportData.BlockedPackages = guardResult.BlockedPackages - reportData.ConfirmedPackages = guardResult.ConfirmedPackages - } - - // Infer outcome from data and config using shared inference logic - blockedCount := 0 - userCancelledCount := 0 - - if guardResult != nil { - blockedCount = guardResult.BlockedCount - // In guard flow, if user cancelled, all blocked packages are due to user cancellation - // (guard returns immediately on ActionBlock, so we can't have both types) - if guardResult.WasUserCancelled { - userCancelledCount = guardResult.BlockedCount - } - } - - reportData.Outcome = inferOutcome(cfg.InsecureInstallation, cfg.DryRun, blockedCount, userCancelledCount, err) - - // Session complete is called here (not deferred) because guard.Run() calls - // LogInstallStarted internally, and all paths after guard.Run() reach this point. - audit.LogSessionComplete(audit.Outcome(reportData.Outcome.String()), audit.FlowTypeGuard) - - // Show the report - ui.Report(reportData) - - // Exit after report for blocked/cancelled outcomes - if reportData.Outcome == ui.OutcomeBlocked || reportData.Outcome == ui.OutcomeUserCancelled { - os.Exit(1) - } - - if err != nil { - return fmt.Errorf("failed to run package manager guard: %w", err) - } - - return nil -} diff --git a/internal/flows/outcome.go b/internal/flows/outcome.go index d8aaa5a..a4896e8 100644 --- a/internal/flows/outcome.go +++ b/internal/flows/outcome.go @@ -3,8 +3,6 @@ package flows import "github.com/safedep/pmg/internal/ui" // inferOutcome determines the execution outcome based on configuration and execution data. -// This function is shared across different flow implementations (guard-based, proxy-based) -// to maintain consistent outcome logic without coupling flows to each other. // // Outcome precedence: // 1. Error (if no packages were blocked) diff --git a/internal/flows/proxy_flow.go b/internal/flows/proxy_flow.go index 92154e7..4661382 100644 --- a/internal/flows/proxy_flow.go +++ b/internal/flows/proxy_flow.go @@ -9,7 +9,6 @@ import ( "github.com/safedep/dry/log" "github.com/safedep/pmg/analyzer" "github.com/safedep/pmg/config" - "github.com/safedep/pmg/guard" "github.com/safedep/pmg/internal/audit" "github.com/safedep/pmg/internal/localstore" "github.com/safedep/pmg/internal/runner" @@ -21,20 +20,37 @@ import ( ) type proxyFlow struct { - pm packagemanager.PackageManager - packageResolver packagemanager.PackageResolver + pm packagemanager.PackageManager } // ProxyFlow creates a new proxy-based flow for package manager protection -func ProxyFlow(pm packagemanager.PackageManager, packageResolver packagemanager.PackageResolver) *proxyFlow { +func ProxyFlow(pm packagemanager.PackageManager) *proxyFlow { return &proxyFlow{ - pm: pm, - packageResolver: packageResolver, + pm: pm, } } +// RunProxy parses args with pm and runs the proxy flow on the parsed command. +// It is the shared entry point for package manager commands. +func RunProxy(ctx context.Context, pm packagemanager.PackageManager, args []string) error { + parsedCommand, err := pm.ParseCommand(args) + if err != nil { + return fmt.Errorf("failed to parse command: %w", err) + } + + return ProxyFlow(pm).Run(ctx, args, parsedCommand) +} + // Run executes the proxy-based flow func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagemanager.ParsedCommand) (runErr error) { + // Guard mode is removed: a config or environment that still disables proxy + // interception must fail loudly instead of being silently switched to proxy + // mode. Checked here rather than at CLI startup so non-install commands + // (pmg config, setup remove, doctor, ...) stay usable to fix the config. + if err := config.RejectRemovedProxyOptOut(); err != nil { + return err + } + // Check if we have a supported ecosystem else fail fast ecosystem := f.pm.Ecosystem() if !interceptors.IsSupported(ecosystem) { @@ -68,7 +84,6 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema reportData.FlowType = ui.FlowTypeProxy reportData.DryRun = cfg.DryRun reportData.InsecureMode = cfg.InsecureInstallation - reportData.TransitiveEnabled = cfg.Config.Transitive reportData.ParanoidMode = cfg.Config.Paranoid reportData.SandboxEnabled = cfg.Config.Sandbox.Enabled @@ -157,12 +172,7 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema // Create interaction callbacks for user prompts // Note: We use a pointer so we can later inject the input reader via SetInput - interaction := &guard.PackageManagerGuardInteraction{ - SetStatus: ui.SetStatus, - ClearStatus: ui.ClearStatus, - ShowWarning: ui.ShowWarning, - Block: ui.BlockNoExit, - } + interaction := &packagemanager.PackageManagerInteraction{} // Extract pinned versions from install targets so cooldown handlers can // report when a user's explicitly requested version was blocked. diff --git a/internal/proxyserver/server.go b/internal/proxyserver/server.go index 707bac6..0d53216 100644 --- a/internal/proxyserver/server.go +++ b/internal/proxyserver/server.go @@ -213,7 +213,6 @@ func logSessionSummary(cfg *config.RuntimeConfig, stats interceptors.AnalysisSta Duration: duration, SandboxEnabled: cfg.Config.Sandbox.Enabled, ParanoidMode: cfg.Config.Paranoid, - TransitiveEnabled: cfg.Config.Transitive, }) } diff --git a/internal/ui/report.go b/internal/ui/report.go index e15cf26..066cd1a 100644 --- a/internal/ui/report.go +++ b/internal/ui/report.go @@ -12,14 +12,11 @@ import ( type FlowType int const ( - FlowTypeGuard FlowType = iota - FlowTypeProxy + FlowTypeProxy FlowType = iota ) func (f FlowType) String() string { switch f { - case FlowTypeGuard: - return "guard" case FlowTypeProxy: return "proxy" default: @@ -66,7 +63,7 @@ type ReportData struct { StartTime time.Time Duration time.Duration - // Package statistics (consistent across guard and proxy flows) + // Package statistics TotalAnalyzed int TrustedSkipped int @@ -87,13 +84,12 @@ type ReportData struct { AdvisoryMessage string // Configuration context - FlowType FlowType - DryRun bool - InsecureMode bool - TransitiveEnabled bool - ParanoidMode bool - SandboxEnabled bool - SandboxProfile string + FlowType FlowType + DryRun bool + InsecureMode bool + ParanoidMode bool + SandboxEnabled bool + SandboxProfile string // Outcome Outcome ExecutionOutcome @@ -280,11 +276,10 @@ func reportVerbose(data *ReportData) { // Configuration section fmt.Println() - fmt.Printf(" %s %s | %s flow | transitive: %s | paranoid: %s\n", + fmt.Printf(" %s %s | %s flow | paranoid: %s\n", Colors.Bold("Config:"), data.PackageManagerName, data.FlowType.String(), - boolToOnOff(data.TransitiveEnabled), boolToOnOff(data.ParanoidMode)) if data.SandboxEnabled { diff --git a/main.go b/main.go index 89cbf60..6ef53c0 100644 --- a/main.go +++ b/main.go @@ -246,9 +246,9 @@ func logDebugContext() { log.Debugf("PMG %s (commit: %s) running on %s/%s with %s", appVersion.Version, appVersion.Commit, runtime.GOOS, runtime.GOARCH, runtime.Version()) log.Debugf("Using config file: %s", cfg.ConfigFilePath()) - log.Debugf("Proxy mode enabled: %t, install only: %t", cfg.IsProxyModeEnabled(), cfg.Config.Proxy.InstallOnly) + log.Debugf("Proxy install only: %t", cfg.Config.Proxy.InstallOnly) log.Debugf("Sandbox enabled: %t, enforce always: %t", cfg.Config.Sandbox.Enabled, cfg.Config.Sandbox.EnforceAlways) - log.Debugf("Transitive analysis enabled: %t (depth: %d), paranoid: %t", cfg.Config.Transitive, cfg.Config.TransitiveDepth, cfg.Config.Paranoid) + log.Debugf("Paranoid mode enabled: %t", cfg.Config.Paranoid) log.Debugf("Dependency cooldown enabled: %t (days: %d)", cfg.Config.DependencyCooldown.Enabled, cfg.Config.DependencyCooldown.Days) log.Debugf("Cloud sync enabled: %t, telemetry disabled: %t", cfg.Config.Cloud.Enabled, cfg.Config.DisableTelemetry) log.Debugf("Dry run: %t, insecure installation: %t, trusted packages: %d", diff --git a/packagemanager/dependency_resolver.go b/packagemanager/dependency_resolver.go deleted file mode 100644 index a9a1e5e..0000000 --- a/packagemanager/dependency_resolver.go +++ /dev/null @@ -1,229 +0,0 @@ -package packagemanager - -import ( - "context" - "fmt" - "sync" - - packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" - "github.com/safedep/dry/log" - "github.com/safedep/dry/packageregistry" -) - -// Contract for a function that implements ecosystem specific version -// resolver from a version range specification. -type versionSpecResolverFn func(packageName, version string) string - -type dependencyResolverFn func(packageName, version string) (*packageregistry.PackageDependencyList, error) - -type packageIdentifierFn func(pkg *packagev1.PackageVersion) string - -type dependencyResolverConfig struct { - IncludeDevDependencies bool - IncludeTransitiveDependencies bool - TransitiveDepth int - FailFast bool - MaxConcurrency int -} - -type dependencyResolver struct { - client packageregistry.Client - config dependencyResolverConfig - mutex sync.Mutex - versionSpecResolver versionSpecResolverFn - packageDependencyResolver dependencyResolverFn - packageIdentifierFn packageIdentifierFn - resultSet map[string]bool -} - -func newDependencyResolver(client packageregistry.Client, config dependencyResolverConfig, - versionSpecResolver versionSpecResolverFn, packageDependencyResolver dependencyResolverFn, packageKeyFn packageIdentifierFn) *dependencyResolver { - if config.MaxConcurrency <= 0 { - config.MaxConcurrency = 10 - } - - if versionSpecResolver == nil { - // Default version spec resolver - versionSpecResolver = func(packageName, version string) string { - return version - } - } - - return &dependencyResolver{ - client: client, - config: config, - versionSpecResolver: versionSpecResolver, - packageDependencyResolver: packageDependencyResolver, - packageIdentifierFn: packageKeyFn, - resultSet: make(map[string]bool), - } -} - -func (r *dependencyResolver) resolveDependencies(ctx context.Context, - packageVersion *packagev1.PackageVersion) ([]*packagev1.PackageVersion, error) { - pd, err := r.client.PackageDiscovery() - if err != nil { - return nil, fmt.Errorf("failed to get package discovery: %w", err) - } - - // Track visited packages to avoid cycles - visitedPackages := make(map[string]bool) - - // Result collection - dependencies := make([]*packagev1.PackageVersion, 0) - - r.resultSet = make(map[string]bool) // Reset - // Start concurrent resolution - err = r.resolvePackageDependenciesConcurrent(ctx, pd, packageVersion, 0, visitedPackages, &dependencies) - if err != nil { - return nil, fmt.Errorf("failed to resolve dependencies: %w", err) - } - - return dependencies, nil -} - -// resolvePackageDependenciesConcurrent resolves dependencies for a package version concurrently -func (r *dependencyResolver) resolvePackageDependenciesConcurrent( - ctx context.Context, - pd packageregistry.PackageDiscovery, - packageVersion *packagev1.PackageVersion, - depth int, - visitedPackages map[string]bool, - result *[]*packagev1.PackageVersion) error { - - // Check for context cancellation - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - ff := func(err error) error { - if r.config.FailFast { - return err - } - - log.Warnf("error resolving package dependencies: %s", err) - return nil - } - - // Check depth limit - if depth > r.config.TransitiveDepth { - return ff(fmt.Errorf("exceeded maximum transitive depth of %d", r.config.TransitiveDepth)) - } - - var packageKey string - var packageKeyFn packageIdentifierFn - - // Skip if already visited - if r.packageIdentifierFn != nil { - packageKeyFn = r.packageIdentifierFn - } else { - packageKeyFn = createPackageKey - } - packageKey = packageKeyFn(packageVersion) - - shouldProcess := false - - r.synchronize(func() { - if !visitedPackages[packageKey] { - visitedPackages[packageKey] = true - shouldProcess = true - } - }) - - // If another goroutine is already processing this package, skip - if !shouldProcess { - return nil - } - - log.Debugf("resolving dependencies for %s@%s", packageVersion.Package.Name, packageVersion.Version) - - // Get dependencies for the current package - var dependencyList *packageregistry.PackageDependencyList - var err error - if r.packageDependencyResolver != nil { - dependencyList, err = r.packageDependencyResolver(packageVersion.Package.Name, packageVersion.Version) - } else { - dependencyList, err = pd.GetPackageDependencies(packageVersion.Package.Name, packageVersion.Version) - } - - if err != nil { - return ff(fmt.Errorf("failed to get package dependencies: %w", err)) - } - - // Collect all dependencies (and optionally dev dependencies) - dependencies := dependencyList.Dependencies - if r.config.IncludeDevDependencies { - dependencies = append(dependencies, dependencyList.DevDependencies...) - } - - // Create package version objects for all dependencies and clean versions - resolvedDependencies := make([]*packagev1.PackageVersion, 0, len(dependencies)) - for _, dependency := range dependencies { - resolvedDependencies = append(resolvedDependencies, &packagev1.PackageVersion{ - Package: &packagev1.Package{ - Ecosystem: packageVersion.GetPackage().GetEcosystem(), - Name: dependency.Name, - }, - Version: r.versionSpecResolver(dependency.Name, dependency.VersionSpec), - }) - } - - // Add resolved dependencies to the result - r.synchronize(func() { - for _, dependency := range resolvedDependencies { - dependencyKey := packageKeyFn(dependency) - - if !r.resultSet[dependencyKey] { - r.resultSet[dependencyKey] = true - *result = append(*result, dependency) - } - } - }) - - // Process transitive dependencies if enabled and depth limit not reached - if r.config.IncludeTransitiveDependencies && depth < r.config.TransitiveDepth && len(resolvedDependencies) > 0 { - // Create worker pool using semaphore pattern - semaphore := make(chan struct{}, r.config.MaxConcurrency) - errCh := make(chan error, len(resolvedDependencies)) - var wg sync.WaitGroup - - for _, dependency := range resolvedDependencies { - wg.Add(1) - - go func(dep *packagev1.PackageVersion) { - defer wg.Done() - - semaphore <- struct{}{} - defer func() { <-semaphore }() - - err := r.resolvePackageDependenciesConcurrent(ctx, pd, dep, depth+1, visitedPackages, result) - if err != nil { - errCh <- err - } - }(dependency) - } - - // Wait for all goroutines to finish - wg.Wait() - close(errCh) - - // Check for errors - for err := range errCh { - return ff(fmt.Errorf("failed to resolve transitive dependency: %w", err)) - } - } - - return nil -} - -func createPackageKey(pkg *packagev1.PackageVersion) string { - return fmt.Sprintf("%s@%s", pkg.Package.Name, pkg.Version) -} - -func (r *dependencyResolver) synchronize(fn func()) { - r.mutex.Lock() - defer r.mutex.Unlock() - fn() -} diff --git a/packagemanager/errors.go b/packagemanager/errors.go index 2084a94..45c482d 100644 --- a/packagemanager/errors.go +++ b/packagemanager/errors.go @@ -6,29 +6,12 @@ import ( ) var ( - ErrPackageNotFound = usefulerror.NewUsefulError(). - WithCode(errcodes.NotFound). - WithHumanError("The requested package could not be found."). - WithHelp("Please check the package name and try again.") - - ErrFailedToFetchPackage = usefulerror.NewUsefulError(). - WithCode(errcodes.Network). - WithHumanError("Failed to retrieve the requested package."). - WithHelp("Check your network connection and try again."). - WithMsg("failed to fetch package") - ErrFailedToResolveVersion = usefulerror.NewUsefulError(). WithCode(errcodes.Network). WithHumanError("Failed to resolve the requested package version."). WithHelp("Check your network connection and try again."). WithMsg("failed to resolve package version") - ErrFailedToResolveDependencies = usefulerror.NewUsefulError(). - WithCode(errcodes.DependencyResolutionFailed). - WithHumanError("Failed to resolve dependencies."). - WithHelp("Check your network connection and try again."). - WithMsg("failed to resolve dependencies") - ErrFailedToParsePackage = usefulerror.NewUsefulError(). WithCode(errcodes.PackageParseFailed). WithHumanError("The package data could not be processed."). diff --git a/packagemanager/golang.go b/packagemanager/golang.go index bbb3293..621df05 100644 --- a/packagemanager/golang.go +++ b/packagemanager/golang.go @@ -76,12 +76,10 @@ func (g *goPackageManager) ParseCommand(args []string) (*ParsedCommand, error) { switch modCmd { case "tidy": parsed.IsManifestInstall = true - parsed.ManifestFiles = []string{"go.mod"} case "download": parsed.InstallTargets = goRemoteModuleTargets(modRest) if len(parsed.InstallTargets) == 0 { parsed.IsManifestInstall = true - parsed.ManifestFiles = []string{"go.mod"} } } } diff --git a/packagemanager/golang_test.go b/packagemanager/golang_test.go index adfabda..15dc510 100644 --- a/packagemanager/golang_test.go +++ b/packagemanager/golang_test.go @@ -16,12 +16,11 @@ func TestGoPackageManagerParseCommand(t *testing.T) { } cases := []struct { - name string - args []string - nonDownload bool - manifestInstall bool - targets []target - wantManifestFiles []string + name string + args []string + nonDownload bool + manifestInstall bool + targets []target }{ { name: "go version is non-download", @@ -88,16 +87,14 @@ func TestGoPackageManagerParseCommand(t *testing.T) { targets: []target{{name: "example.com/m", version: "v2.0.0", explicit: true}}, }, { - name: "go mod tidy is manifest install", - args: []string{"go", "mod", "tidy"}, - manifestInstall: true, - wantManifestFiles: []string{"go.mod"}, + name: "go mod tidy is manifest install", + args: []string{"go", "mod", "tidy"}, + manifestInstall: true, }, { - name: "go mod download without args is manifest install", - args: []string{"go", "mod", "download"}, - manifestInstall: true, - wantManifestFiles: []string{"go.mod"}, + name: "go mod download without args is manifest install", + args: []string{"go", "mod", "download"}, + manifestInstall: true, }, { name: "go mod download with module", @@ -123,7 +120,6 @@ func TestGoPackageManagerParseCommand(t *testing.T) { assert.Equal(t, tc.nonDownload, parsed.IsKnownNonDownloadCommand) assert.Equal(t, tc.manifestInstall, parsed.IsManifestInstall) - assert.Equal(t, tc.wantManifestFiles, parsed.ManifestFiles) require.Len(t, parsed.InstallTargets, len(tc.targets)) for i, want := range tc.targets { diff --git a/packagemanager/noop_resolver.go b/packagemanager/noop_resolver.go deleted file mode 100644 index f78aa1b..0000000 --- a/packagemanager/noop_resolver.go +++ /dev/null @@ -1,25 +0,0 @@ -package packagemanager - -import ( - "context" - "fmt" - - packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" -) - -// noopPackageResolver satisfies PackageResolver for flows that never resolve -// dependencies up front, such as the proxy flow where every download is -// intercepted and analyzed on the wire. -type noopPackageResolver struct{} - -func NewNoopPackageResolver() PackageResolver { - return noopPackageResolver{} -} - -func (noopPackageResolver) ResolveLatestVersion(context.Context, *packagev1.Package) (*packagev1.PackageVersion, error) { - return nil, fmt.Errorf("package resolution is not supported by the noop resolver") -} - -func (noopPackageResolver) ResolveDependencies(context.Context, *packagev1.PackageVersion) ([]*packagev1.PackageVersion, error) { - return nil, fmt.Errorf("dependency resolution is not supported by the noop resolver") -} diff --git a/packagemanager/npm.go b/packagemanager/npm.go index 0398fe5..fb3600d 100644 --- a/packagemanager/npm.go +++ b/packagemanager/npm.go @@ -102,7 +102,6 @@ func (npm *npmPackageManager) ParseCommand(args []string) (*ParsedCommand, error Command: command, InstallTargets: []*PackageInstallTarget{}, IsManifestInstall: true, - ManifestFiles: []string{}, }, nil } @@ -205,7 +204,6 @@ func (npm *npmPackageManager) ParseCommand(args []string) (*ParsedCommand, error Command: command, InstallTargets: installTargets, IsManifestInstall: isManifestInstall, - ManifestFiles: []string{}, }, nil } diff --git a/packagemanager/npm_resolver.go b/packagemanager/npm_resolver.go deleted file mode 100644 index 351b280..0000000 --- a/packagemanager/npm_resolver.go +++ /dev/null @@ -1,89 +0,0 @@ -package packagemanager - -import ( - "context" - "fmt" - - packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" - "github.com/safedep/dry/log" - "github.com/safedep/dry/packageregistry" -) - -type NpmDependencyResolverConfig struct { - IncludeDevDependencies bool - IncludeTransitiveDependencies bool - TransitiveDepth int - - // FailFast will stop resolving dependencies after the first error - FailFast bool - - // MaxConcurrency limits the number of concurrent goroutines used for dependency resolution - MaxConcurrency int -} - -func NewDefaultNpmDependencyResolverConfig() NpmDependencyResolverConfig { - return NpmDependencyResolverConfig{ - IncludeDevDependencies: false, - IncludeTransitiveDependencies: true, - TransitiveDepth: 5, - FailFast: false, - MaxConcurrency: 10, - } -} - -type npmDependencyResolver struct { - registry packageregistry.Client - config NpmDependencyResolverConfig -} - -var _ PackageResolver = &npmDependencyResolver{} - -func NewNpmDependencyResolver(config NpmDependencyResolverConfig) (*npmDependencyResolver, error) { - client, err := packageregistry.NewNpmAdapter() - if err != nil { - return nil, fmt.Errorf("failed to create npm adapter: %w", err) - } - - return &npmDependencyResolver{ - registry: client, - config: config, - }, nil -} - -func (r *npmDependencyResolver) ResolveLatestVersion(ctx context.Context, - pkg *packagev1.Package) (*packagev1.PackageVersion, error) { - pd, err := r.registry.PackageDiscovery() - if err != nil { - return nil, fmt.Errorf("failed to get package discovery: %w", err) - } - - pkgInfo, err := pd.GetPackage(pkg.Name) - if err != nil { - return nil, ErrFailedToFetchPackage.Wrap(err) - } - - log.Debugf("Resolved npm/%s to latest version %s", pkg.Name, pkgInfo.LatestVersion) - - return &packagev1.PackageVersion{ - Package: pkg, - Version: pkgInfo.LatestVersion, - }, nil -} - -func (r *npmDependencyResolver) ResolveDependencies(ctx context.Context, - packageVersion *packagev1.PackageVersion) ([]*packagev1.PackageVersion, error) { - - npmVersionSpecResolverFn := func(packageName, version string) string { - return npmCleanVersion(version) - } - - resolver := newDependencyResolver(r.registry, dependencyResolverConfig{ - IncludeDevDependencies: r.config.IncludeDevDependencies, - IncludeTransitiveDependencies: r.config.IncludeTransitiveDependencies, - TransitiveDepth: r.config.TransitiveDepth, - FailFast: r.config.FailFast, - MaxConcurrency: r.config.MaxConcurrency, - }, npmVersionSpecResolverFn, nil, nil) - - return resolver.resolveDependencies(ctx, packageVersion) -} diff --git a/packagemanager/npm_resolver_test.go b/packagemanager/npm_resolver_test.go deleted file mode 100644 index 8dd98f3..0000000 --- a/packagemanager/npm_resolver_test.go +++ /dev/null @@ -1,167 +0,0 @@ -package packagemanager - -import ( - "context" - "testing" - - packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" - "github.com/safedep/dry/semver" - "github.com/stretchr/testify/require" -) - -func TestNpmDependencyResolver_ResolveLatestVersion(t *testing.T) { - cases := []struct { - name string - pkg *packagev1.Package - assertFn func(t *testing.T, pv *packagev1.PackageVersion, err error) - }{ - { - name: "should resolve latest version for a package", - pkg: &packagev1.Package{ - Name: "react", - Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM, - }, - assertFn: func(t *testing.T, pv *packagev1.PackageVersion, err error) { - require.NoError(t, err) - require.True(t, semver.IsAhead("19.0.0", pv.Version)) - }, - }, - { - name: "should return an error if the package is not found", - pkg: &packagev1.Package{ - Name: "nonexistent", - Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM, - }, - assertFn: func(t *testing.T, pv *packagev1.PackageVersion, err error) { - require.Error(t, err) - require.Nil(t, pv) - }, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - resolver, err := NewNpmDependencyResolver(NewDefaultNpmDependencyResolverConfig()) - require.NoError(t, err) - - pv, err := resolver.ResolveLatestVersion(context.Background(), tc.pkg) - tc.assertFn(t, pv, err) - }) - } -} - -func TestNpmDependencyResolver_ResolveDependencies(t *testing.T) { - cases := []struct { - name string - pkg *packagev1.PackageVersion - includeTransitiveDependencies bool - transitiveDepth int - failFast bool - assertFn func(t *testing.T, dependencies []*packagev1.PackageVersion, err error) - }{ - { - name: "should resolve dependencies for a package when transitive dependencies are not included", - pkg: &packagev1.PackageVersion{ - Package: &packagev1.Package{ - Name: "react", - Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM, - }, - Version: "18.2.0", - }, - includeTransitiveDependencies: false, - transitiveDepth: 1, - assertFn: func(t *testing.T, dependencies []*packagev1.PackageVersion, err error) { - require.NoError(t, err) - require.Equal(t, 1, len(dependencies)) - require.Equal(t, "loose-envify", dependencies[0].Package.Name) - require.Equal(t, "1.1.0", dependencies[0].Version) - }, - }, - { - name: "should resolve dependencies for a package up to a given depth", - pkg: &packagev1.PackageVersion{ - Package: &packagev1.Package{ - Name: "react", - Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM, - }, - Version: "18.2.0", - }, - includeTransitiveDependencies: true, - transitiveDepth: 2, - assertFn: func(t *testing.T, dependencies []*packagev1.PackageVersion, err error) { - require.NoError(t, err) - require.Equal(t, 2, len(dependencies)) - - packageNames := []string{} - for _, dep := range dependencies { - packageNames = append(packageNames, dep.Package.Name) - } - - require.ElementsMatch(t, []string{ - "loose-envify", - "js-tokens", - }, packageNames) - }, - }, - { - name: "should resolve all dependencies for a package when transitive dependencies are included", - pkg: &packagev1.PackageVersion{ - Package: &packagev1.Package{ - Name: "express", - Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM, - }, - Version: "4.18.2", - }, - includeTransitiveDependencies: true, - transitiveDepth: 5, - assertFn: func(t *testing.T, dependencies []*packagev1.PackageVersion, err error) { - require.NoError(t, err) - require.Greater(t, len(dependencies), 5, "Express should have more than 5 dependencies") - }, - }, - { - name: "should not fail when package is not found without fail fast", - pkg: &packagev1.PackageVersion{ - Package: &packagev1.Package{ - Name: "nonexistent", - Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM, - }, - Version: "1.0.0", - }, - assertFn: func(t *testing.T, dependencies []*packagev1.PackageVersion, err error) { - require.NoError(t, err) - require.Empty(t, dependencies) - }, - }, - { - name: "should fail when package is not found with fail fast", - pkg: &packagev1.PackageVersion{ - Package: &packagev1.Package{ - Name: "nonexistent", - Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM, - }, - Version: "1.0.0", - }, - failFast: true, - assertFn: func(t *testing.T, dependencies []*packagev1.PackageVersion, err error) { - require.Error(t, err) - require.Nil(t, dependencies) - }, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - config := NewDefaultNpmDependencyResolverConfig() - config.IncludeTransitiveDependencies = tc.includeTransitiveDependencies - config.TransitiveDepth = tc.transitiveDepth - config.FailFast = tc.failFast - - resolver, err := NewNpmDependencyResolver(config) - require.NoError(t, err) - - dependencies, err := resolver.ResolveDependencies(context.Background(), tc.pkg) - tc.assertFn(t, dependencies, err) - }) - } -} diff --git a/packagemanager/packagemanager.go b/packagemanager/packagemanager.go index f10f4f2..3411568 100644 --- a/packagemanager/packagemanager.go +++ b/packagemanager/packagemanager.go @@ -1,11 +1,13 @@ package packagemanager import ( - "context" + "io" + "os" "slices" "strings" packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" + "github.com/safedep/pmg/analyzer" ) type Command struct { @@ -16,11 +18,6 @@ type Command struct { type PackageInstallTarget struct { PackageVersion *packagev1.PackageVersion - // Extras specifies additional features to be installed with a Python package - // Example: "django[mysql,redis]" has Extras as ["mysql", "redis"] - // Currently only specific to Python packages - Extras []string - // IsExplicitVersion indicates the user provided an explicit version constraint // (e.g. ==1.2.3) as opposed to the version being auto-resolved by the resolver. IsExplicitVersion bool @@ -41,10 +38,6 @@ type ParsedCommand struct { // (e.g., npm install, pip install -r requirements.txt) IsManifestInstall bool - // ManifestFiles contains the list of manifest files to install from - // (e.g., ["requirements.txt"] for pip install -r requirements.txt) - ManifestFiles []string - // IsKnownNonDownloadCommand is true for commands that are known to not download packages // (e.g., npm ls, pip list, yarn why). Used by the proxy to decide whether to skip // interception when proxy.install_only is enabled. Unknown commands default to false so @@ -53,7 +46,6 @@ type ParsedCommand struct { } // IsInstallationCommand returns true if command installs packages (explicit targets or from manifest). -// This is used by guard mode where we need to know which packages are being installed. func (pc *ParsedCommand) IsInstallationCommand() bool { return pc.HasInstallTarget() || pc.HasManifestInstall() } @@ -73,10 +65,6 @@ func (pc *ParsedCommand) HasManifestInstall() bool { return pc.IsManifestInstall } -func (pc *ParsedCommand) ShouldExtractFromManifest() bool { - return pc.IsManifestInstall && !pc.HasInstallTarget() -} - // IsFirstNonFlagArgInList checks if the first non-flag argument in args is in the given list. // Only the first non-flag arg (the subcommand) is checked to avoid false positives when package // names or script arguments happen to match a known command. @@ -103,14 +91,27 @@ type PackageManager interface { Ecosystem() packagev1.Ecosystem } -// PackageResolver is the contract for resolving package info -type PackageResolver interface { - // ResolveLatestVersion resolves the latest version for a given package - ResolveLatestVersion(context.Context, *packagev1.Package) (*packagev1.PackageVersion, error) +// PackageManagerInteraction carries the confirmation prompt callback and input +// routing used by proxy-mode malware confirmations. +type PackageManagerInteraction struct { + // GetConfirmationOnMalware is called to get the confirmation of the user on the malware packages + GetConfirmationOnMalware func(malwarePackages []*analyzer.PackageVersionAnalysisResult) (bool, error) - // ResolveDependencies resolves the dependencies for a given package version - // It returns a flattened list of all the dependencies based on implementation - // specific config. The version resolution is based on minimum version selection - // for a given version range. - ResolveDependencies(context.Context, *packagev1.PackageVersion) ([]*packagev1.PackageVersion, error) + // inputReader is the reader to use for user input during confirmations. + // If nil, os.Stdin is used. This is set via SetInput to allow PTY input routing. + inputReader io.Reader +} + +// SetInput sets the input reader for user confirmations. +// This allows the PTY switchboard to route input to the prompt during confirmations. +func (i *PackageManagerInteraction) SetInput(r io.Reader) { + i.inputReader = r +} + +// Reader returns the configured input reader, or os.Stdin if none is set. +func (i *PackageManagerInteraction) Reader() io.Reader { + if i.inputReader != nil { + return i.inputReader + } + return os.Stdin } diff --git a/packagemanager/pypi.go b/packagemanager/pypi.go index dca825a..c1bc029 100644 --- a/packagemanager/pypi.go +++ b/packagemanager/pypi.go @@ -172,14 +172,10 @@ func (p *pipCommandParser) ParseCommand(args []string) (*ParsedCommand, error) { // Determine if this is a manifest install isManifestInstall := len(requirementFiles) > 0 - // Combine all manifest files - var allManifestFiles []string - allManifestFiles = append(allManifestFiles, requirementFiles...) - // Process packages var installTargets []*PackageInstallTarget for _, pkg := range packages { - packageName, version, extras, err := pypiParsePackageInfo(pkg) + packageName, version, err := pypiParsePackageInfo(pkg) if err != nil { return nil, ErrFailedToParsePackage.Wrap(err) } @@ -199,7 +195,6 @@ func (p *pipCommandParser) ParseCommand(args []string) (*ParsedCommand, error) { }, Version: version, }, - Extras: extras, IsExplicitVersion: isExplicit, }) } @@ -208,7 +203,6 @@ func (p *pipCommandParser) ParseCommand(args []string) (*ParsedCommand, error) { Command: command, InstallTargets: installTargets, IsManifestInstall: isManifestInstall, - ManifestFiles: allManifestFiles, }, nil } @@ -239,19 +233,15 @@ func (u *uvCommandParser) ParseCommand(args []string) (*ParsedCommand, error) { Command: command, InstallTargets: nil, IsManifestInstall: true, - ManifestFiles: []string{"uv.lock"}, }, nil } // Handles pip sync command (installs from requirements.txt style files) if len(args) >= 3 && args[0] == "pip" && args[1] == "sync" { - manifestFile := args[2] - return &ParsedCommand{ Command: command, InstallTargets: nil, IsManifestInstall: true, - ManifestFiles: []string{manifestFile}, }, nil } @@ -298,7 +288,7 @@ func (u *uvCommandParser) ParseCommand(args []string) (*ParsedCommand, error) { var installTargets []*PackageInstallTarget for _, pkg := range packages { - packageName, version, extras, err := pypiParsePackageInfo(pkg) + packageName, version, err := pypiParsePackageInfo(pkg) if err != nil { return nil, ErrFailedToParsePackage.Wrap(err) } @@ -318,7 +308,6 @@ func (u *uvCommandParser) ParseCommand(args []string) (*ParsedCommand, error) { }, Version: version, }, - Extras: extras, IsExplicitVersion: isExplicit, }) } @@ -327,7 +316,6 @@ func (u *uvCommandParser) ParseCommand(args []string) (*ParsedCommand, error) { Command: command, InstallTargets: installTargets, IsManifestInstall: isManifestInstall, - ManifestFiles: manifestFiles, }, nil } @@ -357,7 +345,6 @@ func (p *poetryCommandParser) ParseCommand(args []string) (*ParsedCommand, error Command: command, IsManifestInstall: true, InstallTargets: nil, - ManifestFiles: []string{"poetry.lock"}, }, nil } @@ -397,7 +384,7 @@ func (p *poetryCommandParser) ParseCommand(args []string) (*ParsedCommand, error return nil, ErrFailedToParsePackage.Wrap(err) } - packageName, version, extras, err := pypiParsePackageInfo(convertedPkg) + packageName, version, err := pypiParsePackageInfo(convertedPkg) if err != nil { return nil, ErrFailedToParsePackage.Wrap(err) } @@ -417,7 +404,6 @@ func (p *poetryCommandParser) ParseCommand(args []string) (*ParsedCommand, error }, Version: version, }, - Extras: extras, IsExplicitVersion: isExplicit, }) } @@ -426,38 +412,27 @@ func (p *poetryCommandParser) ParseCommand(args []string) (*ParsedCommand, error Command: command, InstallTargets: installTargets, IsManifestInstall: false, - ManifestFiles: nil, }, nil } -// pypiParsePackageInfo parses a python package installation specification, separating the package name, -// version constraints, and any extras (additional features) to be installed. -// Example: "django[mysql,redis]>=3.0" returns ("django", ">=3.0", ["mysql", "redis"], nil) -func pypiParsePackageInfo(input string) (packageName, version string, extras []string, err error) { +// pypiParsePackageInfo parses a python package installation specification, +// separating the package name from version constraints and stripping extras. +// Example: "django[mysql,redis]>=3.0" returns ("django", ">=3.0", nil) +func pypiParsePackageInfo(input string) (packageName, version string, err error) { if input == "" { - return "", "", nil, fmt.Errorf("package info cannot be empty") + return "", "", fmt.Errorf("package info cannot be empty") } input = strings.TrimSpace(input) - // First extract any extras if present + // Strip any extras (e.g. "[all]") so they never leak into the package name openBracket := strings.Index(input, "[") closeBracket := strings.Index(input, "]") if openBracket != -1 && closeBracket != -1 && openBracket < closeBracket { - extrasStr := strings.TrimSpace(input[openBracket+1 : closeBracket]) - if extrasStr != "" { - // Split extras by comma and trim each extra - for _, extra := range strings.Split(extrasStr, ",") { - if trimmedExtra := strings.TrimSpace(extra); trimmedExtra != "" { - extras = append(extras, trimmedExtra) - } - } - } - // Remove the extra part from input for further processing input = input[:openBracket] + input[closeBracket+1:] } else if (openBracket != -1 && closeBracket == -1) || (openBracket == -1 && closeBracket != -1) { - return "", "", nil, fmt.Errorf("mismatched brackets in input '%s'", input) + return "", "", fmt.Errorf("mismatched brackets in input '%s'", input) } // Python package version specifiers are typically separated by one of: @@ -475,17 +450,17 @@ func pypiParsePackageInfo(input string) (packageName, version string, extras []s if index == -1 { // No operator found, whole input is package name, no version - return strings.TrimSpace(input), "", extras, nil + return strings.TrimSpace(input), "", nil } packageName = strings.TrimSpace(input[:index]) version = strings.TrimSpace(input[index:]) if packageName == "" { - return "", "", nil, fmt.Errorf("invalid package name in input '%s'", input) + return "", "", fmt.Errorf("invalid package name in input '%s'", input) } - return packageName, version, extras, nil + return packageName, version, nil } // pypiConvertPoetryVersionConstraints converts Poetry's caret (^) and tilde (~) version constraints diff --git a/packagemanager/pypi_executor.go b/packagemanager/pypi_executor.go index 7d3eba1..4223584 100644 --- a/packagemanager/pypi_executor.go +++ b/packagemanager/pypi_executor.go @@ -193,7 +193,7 @@ func (p *pypiPackageExecutor) buildInstallTargets(command Command, packages []st var installTargets []*PackageInstallTarget for _, pkg := range packages { - packageName, version, extras, err := pypiParsePackageInfo(pkg) + packageName, version, err := pypiParsePackageInfo(pkg) if err != nil { return nil, ErrFailedToParsePackage.Wrap(err) } @@ -212,7 +212,6 @@ func (p *pypiPackageExecutor) buildInstallTargets(command Command, packages []st }, Version: version, }, - Extras: extras, IsExplicitVersion: isExplicit, }) } diff --git a/packagemanager/pypi_resolver.go b/packagemanager/pypi_resolver.go index 0fcfcf9..ea7126c 100644 --- a/packagemanager/pypi_resolver.go +++ b/packagemanager/pypi_resolver.go @@ -1,256 +1,13 @@ package packagemanager import ( - "context" - "encoding/json" "fmt" - "net/http" - "regexp" - "slices" "strings" - packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" "github.com/Masterminds/semver" - "github.com/safedep/dry/log" "github.com/safedep/dry/packageregistry" ) -type PyPiDependencyResolverConfig struct { - IncludeDevDependencies bool - IncludeTransitiveDependencies bool - TransitiveDepth int - - // FailFast will stop resolving dependencies after the first error - FailFast bool - - // MaxConcurrency limits the number of concurrent goroutines used for dependency resolution - MaxConcurrency int - - PackageInstallTargets []*PackageInstallTarget -} - -func NewDefaultPypiDependencyResolverConfig() PyPiDependencyResolverConfig { - return PyPiDependencyResolverConfig{ - IncludeDevDependencies: false, - IncludeTransitiveDependencies: true, - TransitiveDepth: 5, - FailFast: false, - MaxConcurrency: 10, - PackageInstallTargets: []*PackageInstallTarget{}, - } -} - -type pypiDependencyResolver struct { - registry packageregistry.Client - config PyPiDependencyResolverConfig -} - -var _ PackageResolver = &pypiDependencyResolver{} - -func NewPypiDependencyResolver(config PyPiDependencyResolverConfig) (*pypiDependencyResolver, error) { - client, err := packageregistry.NewPypiAdapter() - if err != nil { - return nil, fmt.Errorf("failed to create pypi adapter: %w", err) - } - - return &pypiDependencyResolver{ - config: config, - registry: client, - }, nil -} - -func (p *pypiDependencyResolver) ResolveDependencies(ctx context.Context, pkg *packagev1.PackageVersion) ([]*packagev1.PackageVersion, error) { - pypiVersionSpecResolverFn := func(packageName, version string) string { - ver, err := pypiGetMatchingVersion(packageName, version) - if err != nil { - log.Debugf("error getting matching version for %s@%s", packageName, version) - return "" - } - return ver - } - - pypiDependencyResolverFn := func(packageName, version string) (*packageregistry.PackageDependencyList, error) { - resolvedDependencies, err := getPypiPackageDependencies(packageName, version, p.config.PackageInstallTargets) - if err != nil { - return nil, err - } - dependencies := make([]packageregistry.PackageDependencyInfo, 0) - for _, dep := range resolvedDependencies { - dependencies = append(dependencies, packageregistry.PackageDependencyInfo{ - Name: dep.PackageNameExtra, - VersionSpec: dep.VersionSpec, - }) - } - - return &packageregistry.PackageDependencyList{ - Dependencies: dependencies, - }, nil - } - - // Python treats package names with '-' and '_' as equivalent (e.g., 'my-package' and 'my_package' refer to the same package) - packageKeyFn := func(pkg *packagev1.PackageVersion) string { - normalizedName := normalizePackageName(pkg.Package.Name) - return fmt.Sprintf("%s@%s", normalizedName, pkg.Version) - } - - resolver := newDependencyResolver(p.registry, dependencyResolverConfig{ - IncludeDevDependencies: p.config.IncludeDevDependencies, - IncludeTransitiveDependencies: p.config.IncludeTransitiveDependencies, - TransitiveDepth: p.config.TransitiveDepth, - FailFast: p.config.FailFast, - MaxConcurrency: p.config.MaxConcurrency, - }, pypiVersionSpecResolverFn, pypiDependencyResolverFn, packageKeyFn) - - return resolver.resolveDependencies(ctx, pkg) -} - -func (p *pypiDependencyResolver) ResolveLatestVersion(ctx context.Context, pkg *packagev1.Package) (*packagev1.PackageVersion, error) { - pd, err := p.registry.PackageDiscovery() - if err != nil { - return nil, fmt.Errorf("failed to get package discovery: %w", err) - } - - pkgInfo, err := pd.GetPackage(pkg.Name) - if err != nil { - return nil, fmt.Errorf("failed to get package: %w", err) - } - log.Debugf("Resolved pypi/%s to latest version %s", pkg.Name, pkgInfo.LatestVersion) - - return &packagev1.PackageVersion{ - Package: pkg, - Version: pkgInfo.LatestVersion, - }, nil -} - -type pypiPackage struct { - Info pypiPackageInfo `json:"info"` - Releases map[string]any `json:"releases"` -} - -type PyPIDependencySpec struct { - // PackageNameExtra is the package name including any direct extras in brackets - // Example: "uvicorn[standard]" - PackageNameExtra string - - // VersionSpec is the version constraint for the package - // Example: ">=0.12.0", "==1.0.0", ">=2.0,<3.0" - VersionSpec string - - // Extra is the conditional extra marker that defines when this dependency applies - // Example: "all" from "; extra == \"all\"" - Extra string -} - -type pypiPackageInfo struct { - Name string `json:"name"` - Description string `json:"summary"` - LatestVersion string `json:"version"` - PackageURL string `json:"package_url"` - Author string `json:"author"` - AuthorEmail string `json:"author_email"` - Maintainer string `json:"maintainer"` - MaintainerEmail string `json:"maintainer_email"` - RequiresDist []string `json:"requires_dist"` -} - -func getPypiPackageDependencies(packageName, version string, packageTargets []*PackageInstallTarget) ([]PyPIDependencySpec, error) { - url := fmt.Sprintf("https://pypi.org/pypi/%s/%s/json", packageName, version) - - res, err := http.Get(url) - if err != nil { - return nil, ErrFailedToFetchPackage.Wrap(err) - } - - if res.StatusCode == 404 { - return nil, ErrPackageNotFound.Wrap(err) - } - - if res.StatusCode != 200 { - return nil, ErrFailedToFetchPackage.Wrap(err) - } - defer func() { - if err := res.Body.Close(); err != nil { - log.Warnf("failed to close PyPI response body: %v", err) - } - }() - - var pypipkg pypiPackage - err = json.NewDecoder(res.Body).Decode(&pypipkg) - if err != nil { - return nil, ErrFailedToParsePackage.Wrap(err) - } - - // Find if this package has any specified extras in the install targets - var requestedExtras []string - for _, target := range packageTargets { - if target.PackageVersion.Package.Name == packageName { - requestedExtras = target.Extras - break - } - } - - pkgDeps := make([]PyPIDependencySpec, 0, len(pypipkg.Info.RequiresDist)) - - for _, dep := range pypipkg.Info.RequiresDist { - name, version, extra := pypiParseDependency(dep) - - // Include dependencies if they either: - // 1. Have no extras (base dependencies) - // 2. Have an extra that matches one of our requested extras - if extra == "" || (len(requestedExtras) > 0 && slices.Contains(requestedExtras, extra)) { - pkgDeps = append(pkgDeps, PyPIDependencySpec{ - PackageNameExtra: name, - VersionSpec: version, - Extra: extra, - }) - } - } - - return pkgDeps, nil -} - -// pypiParseDependency parses a PyPI dependency specification, handling both package extras -// and conditional dependencies. Keeps extras as part of the package name. -// Example: "uvicorn[standard]>=0.12.0; extra == \"all\"" returns ("uvicorn[standard]", ">=0.12.0", "all") -func pypiParseDependency(input string) (string, string, string) { - // Split line by ';' to separate version and markers - parts := strings.SplitN(input, ";", 2) - mainPart := strings.TrimSpace(parts[0]) - - // Regex to match the first occurrence of version operators - // Using lookahead to ensure we match standalone operators - operatorRegex := regexp.MustCompile(`(==|>=|<=|!=|>|<|~=)(?:\d|$)`) - match := operatorRegex.FindStringIndex(mainPart) - - var name, version string - if match != nil { - // Everything before the operator is the name - name = strings.TrimSpace(mainPart[:match[0]]) - // Remove trailing parentheses from name if present - name = strings.TrimRight(name, " (") - - // Everything from the operator onwards is the version spec - version = strings.TrimSpace(mainPart[match[0]:]) - // Remove parentheses from version spec if present - version = strings.Trim(version, "()") - } else { - // No version operator found - name = mainPart - version = "" - } - - // Extract extra marker if present - var extra string - if len(parts) == 2 { - extraRe := regexp.MustCompile(`extra\s*==\s*["']([^"']+)["']`) - if match := extraRe.FindStringSubmatch(parts[1]); len(match) == 2 { - extra = match[1] - } - } - - return name, version, extra -} - func pypiGetMatchingVersion(packageName, versionConstraint string) (string, error) { // Already a exact version if after, ok := strings.CutPrefix(versionConstraint, "=="); ok { @@ -338,14 +95,3 @@ func findBestMatchingVersion(releases []packageregistry.PackageVersionInfo, cons } return bestMatch, nil } - -func normalizePackageName(name string) string { - // Convert to lowercase - name = strings.ToLower(name) - - // Replace any sequence of [-_.] with a single hyphen - re := regexp.MustCompile(`[-_.]+`) - name = re.ReplaceAllString(name, "-") - - return name -} diff --git a/packagemanager/pypi_resolver_test.go b/packagemanager/pypi_resolver_test.go index 7ab073f..90774ea 100644 --- a/packagemanager/pypi_resolver_test.go +++ b/packagemanager/pypi_resolver_test.go @@ -1,56 +1,13 @@ package packagemanager import ( - "context" "fmt" "testing" - packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" "github.com/safedep/dry/semver" "github.com/stretchr/testify/require" ) -func TestPypiDependencyResolver_ResolveLatestVersion(t *testing.T) { - cases := []struct { - name string - pkg *packagev1.Package - assertFn func(t *testing.T, pv *packagev1.PackageVersion, err error) - }{ - { - name: "should resolve latest version for a package", - pkg: &packagev1.Package{ - Name: "requests", - Ecosystem: packagev1.Ecosystem_ECOSYSTEM_PYPI, - }, - assertFn: func(t *testing.T, pv *packagev1.PackageVersion, err error) { - require.NoError(t, err) - require.True(t, semver.IsAhead("2.30.0", pv.Version)) - }, - }, - { - name: "should return an error if the package is not found", - pkg: &packagev1.Package{ - Name: "nonexistent-package-12345", - Ecosystem: packagev1.Ecosystem_ECOSYSTEM_PYPI, - }, - assertFn: func(t *testing.T, pv *packagev1.PackageVersion, err error) { - require.Error(t, err) - require.Nil(t, pv) - }, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - resolver, err := NewPypiDependencyResolver(NewDefaultPypiDependencyResolverConfig()) - require.NoError(t, err) - - pv, err := resolver.ResolveLatestVersion(context.Background(), tc.pkg) - tc.assertFn(t, pv, err) - }) - } -} - func TestPipGetLatestMatchingVersion(t *testing.T) { cases := []struct { name string @@ -96,107 +53,3 @@ func TestPipGetLatestMatchingVersion(t *testing.T) { }) } } - -func TestPypiParseDependency(t *testing.T) { - tests := []struct { - name string - input string - wantName string - wantVersion string - wantExtra string - }{ - { - name: "simple package without version", - input: "requests", - wantName: "requests", - wantVersion: "", - wantExtra: "", - }, - { - name: "package with exact version", - input: "requests==2.28.1", - wantName: "requests", - wantVersion: "==2.28.1", - wantExtra: "", - }, - { - name: "package with greater than version", - input: "django>=4.2.0", - wantName: "django", - wantVersion: ">=4.2.0", - wantExtra: "", - }, - { - name: "package with less than version", - input: "pylint<3.0.0", - wantName: "pylint", - wantVersion: "<3.0.0", - wantExtra: "", - }, - { - name: "package with not equal version", - input: "pytest!=3.0.0", - wantName: "pytest", - wantVersion: "!=3.0.0", - wantExtra: "", - }, - { - name: "package with compatible release version", - input: "sphinx~=4.0.0", - wantName: "sphinx", - wantVersion: "~=4.0.0", - wantExtra: "", - }, - { - name: "package with extra", - input: "requests;extra=='security'", - wantName: "requests", - wantVersion: "", - wantExtra: "security", - }, - { - name: "package with version and extra", - input: "requests>=2.28.1;extra=='security'", - wantName: "requests", - wantVersion: ">=2.28.1", - wantExtra: "security", - }, - { - name: "package with single quotes in extra", - input: "django>=4.2.0;extra=='testing'", - wantName: "django", - wantVersion: ">=4.2.0", - wantExtra: "testing", - }, - { - name: "package with double quotes in extra", - input: "django>=4.2.0;extra==\"testing\"", - wantName: "django", - wantVersion: ">=4.2.0", - wantExtra: "testing", - }, - { - name: "package with multiple version constraints", - input: "requests>=2.28.1,<3.0.0", - wantName: "requests", - wantVersion: ">=2.28.1,<3.0.0", - wantExtra: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotName, gotVersion, gotExtra := pypiParseDependency(tt.input) - - if gotName != tt.wantName { - t.Errorf("pypiParseDependency() gotName = %v, want %v", gotName, tt.wantName) - } - if gotVersion != tt.wantVersion { - t.Errorf("pypiParseDependency() gotVersion = %v, want %v", gotVersion, tt.wantVersion) - } - if gotExtra != tt.wantExtra { - t.Errorf("pypiParseDependency() gotExtra = %v, want %v", gotExtra, tt.wantExtra) - } - }) - } -} diff --git a/packagemanager/pypi_test.go b/packagemanager/pypi_test.go index 3d2f3fb..2d475be 100644 --- a/packagemanager/pypi_test.go +++ b/packagemanager/pypi_test.go @@ -13,7 +13,6 @@ func TestPipParsePackageInfo(t *testing.T) { input string pkgName string version string - extras []string wantErr bool }{ { @@ -21,7 +20,6 @@ func TestPipParsePackageInfo(t *testing.T) { input: "fastapi", pkgName: "fastapi", version: "", - extras: nil, wantErr: false, }, { @@ -29,7 +27,6 @@ func TestPipParsePackageInfo(t *testing.T) { input: "fastapi[all]==0.115.7", pkgName: "fastapi", version: "==0.115.7", - extras: []string{"all"}, wantErr: false, }, { @@ -37,7 +34,6 @@ func TestPipParsePackageInfo(t *testing.T) { input: "requests>=2.0,<3.0", pkgName: "requests", version: ">=2.0,<3.0", - extras: nil, wantErr: false, }, { @@ -52,7 +48,6 @@ func TestPipParsePackageInfo(t *testing.T) { input: "django~=3.1.0", pkgName: "django", version: "~=3.1.0", - extras: nil, wantErr: false, }, { @@ -60,7 +55,6 @@ func TestPipParsePackageInfo(t *testing.T) { input: "numpy[]>1.20.0", pkgName: "numpy", version: ">1.20.0", - extras: nil, wantErr: false, }, { @@ -68,7 +62,6 @@ func TestPipParsePackageInfo(t *testing.T) { input: "pandas<2.0.0", pkgName: "pandas", version: "<2.0.0", - extras: nil, wantErr: false, }, { @@ -76,7 +69,6 @@ func TestPipParsePackageInfo(t *testing.T) { input: "", pkgName: "", version: "", - extras: nil, wantErr: true, }, { @@ -84,7 +76,6 @@ func TestPipParsePackageInfo(t *testing.T) { input: "==1.0.0", pkgName: "", version: "", - extras: nil, wantErr: true, }, { @@ -92,21 +83,19 @@ func TestPipParsePackageInfo(t *testing.T) { input: " requests == 2.0.0 ", pkgName: "requests", version: "== 2.0.0", - extras: nil, wantErr: false, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - pkgName, version, extras, err := pypiParsePackageInfo(tc.input) + pkgName, version, err := pypiParsePackageInfo(tc.input) if tc.wantErr { assert.Error(t, err) } else { assert.NoError(t, err) assert.Equal(t, tc.pkgName, pkgName) assert.Equal(t, tc.version, version) - assert.Equal(t, tc.extras, extras) } }) } @@ -120,70 +109,60 @@ func TestPipParseCommand(t *testing.T) { name string args []string expectedManifest bool - expectedFiles []string expectedTargets int }{ { name: "pip install with -r flag", args: []string{"install", "-r", "requirements.txt"}, expectedManifest: true, - expectedFiles: []string{"requirements.txt"}, expectedTargets: 0, }, { name: "pip install with -r flag with different filename", args: []string{"install", "-r", "requirements-dev.txt"}, expectedManifest: true, - expectedFiles: []string{"requirements-dev.txt"}, expectedTargets: 0, }, { name: "pip install with --requirement flag", args: []string{"install", "--requirement", "requirements.txt"}, expectedManifest: true, - expectedFiles: []string{"requirements.txt"}, expectedTargets: 0, }, { name: "pip install with combined -r flag", args: []string{"install", "-rrequirements.txt"}, expectedManifest: true, - expectedFiles: []string{"requirements.txt"}, expectedTargets: 0, }, { name: "pip install without args", args: []string{"install"}, expectedManifest: false, - expectedFiles: nil, expectedTargets: 0, }, { name: "pip install with explicit package", args: []string{"install", "django"}, expectedManifest: false, - expectedFiles: nil, expectedTargets: 1, }, { name: "pip install with mixed args", args: []string{"install", "django", "-r", "requirements.txt"}, expectedManifest: true, - expectedFiles: []string{"requirements.txt"}, expectedTargets: 1, }, { name: "pip install with multiple -r flags", args: []string{"install", "-r", "requirements.txt", "-r", "dev-requirements.txt"}, expectedManifest: true, - expectedFiles: []string{"requirements.txt", "dev-requirements.txt"}, expectedTargets: 0, }, { name: "non-install command", args: []string{"list"}, expectedManifest: false, - expectedFiles: nil, expectedTargets: 0, }, } @@ -194,14 +173,10 @@ func TestPipParseCommand(t *testing.T) { assert.NoError(t, err) assert.Equal(t, tc.expectedManifest, parsed.IsManifestInstall, "IsManifestInstall mismatch") - assert.Equal(t, tc.expectedFiles, parsed.ManifestFiles, "ManifestFiles mismatch") assert.Equal(t, tc.expectedTargets, len(parsed.InstallTargets), "InstallTargets count mismatch") // Test helper methods assert.Equal(t, tc.expectedManifest, parsed.HasManifestInstall(), "HasManifestInstall mismatch") - - expectedShouldExtract := tc.expectedManifest && tc.expectedTargets == 0 - assert.Equal(t, expectedShouldExtract, parsed.ShouldExtractFromManifest(), "ShouldExtractFromManifest mismatch") }) } } @@ -214,70 +189,60 @@ func TestPip3ParseCommand(t *testing.T) { name string args []string expectedManifest bool - expectedFiles []string expectedTargets int }{ { name: "pip3 install with -r flag", args: []string{"install", "-r", "requirements.txt"}, expectedManifest: true, - expectedFiles: []string{"requirements.txt"}, expectedTargets: 0, }, { name: "pip3 install with -r flag with different filename", args: []string{"install", "-r", "requirements-dev.txt"}, expectedManifest: true, - expectedFiles: []string{"requirements-dev.txt"}, expectedTargets: 0, }, { name: "pip3 install with --requirement flag", args: []string{"install", "--requirement", "requirements.txt"}, expectedManifest: true, - expectedFiles: []string{"requirements.txt"}, expectedTargets: 0, }, { name: "pip3 install with combined -r flag", args: []string{"install", "-rrequirements.txt"}, expectedManifest: true, - expectedFiles: []string{"requirements.txt"}, expectedTargets: 0, }, { name: "pip3 install without args", args: []string{"install"}, expectedManifest: false, - expectedFiles: nil, expectedTargets: 0, }, { name: "pip3 install with explicit package", args: []string{"install", "django"}, expectedManifest: false, - expectedFiles: nil, expectedTargets: 1, }, { name: "pip3 install with mixed args", args: []string{"install", "django", "-r", "requirements.txt"}, expectedManifest: true, - expectedFiles: []string{"requirements.txt"}, expectedTargets: 1, }, { name: "pip3 install with multiple -r flags", args: []string{"install", "-r", "requirements.txt", "-r", "dev-requirements.txt"}, expectedManifest: true, - expectedFiles: []string{"requirements.txt", "dev-requirements.txt"}, expectedTargets: 0, }, { name: "non-install command", args: []string{"list"}, expectedManifest: false, - expectedFiles: nil, expectedTargets: 0, }, } @@ -288,13 +253,9 @@ func TestPip3ParseCommand(t *testing.T) { assert.NoError(t, err) assert.Equal(t, tc.expectedManifest, parsed.IsManifestInstall, "IsManifestInstall mismatch") - assert.Equal(t, tc.expectedFiles, parsed.ManifestFiles, "ManifestFiles mismatch") assert.Equal(t, tc.expectedTargets, len(parsed.InstallTargets), "InstallTargets count mismatch") assert.Equal(t, tc.expectedManifest, parsed.HasManifestInstall(), "HasManifestInstall mismatch") - - expectedShouldExtract := tc.expectedManifest && tc.expectedTargets == 0 - assert.Equal(t, expectedShouldExtract, parsed.ShouldExtractFromManifest(), "ShouldExtractFromManifest mismatch") }) } } @@ -695,7 +656,6 @@ func TestUvParseCommand(t *testing.T) { name string args []string expectedManifest bool - expectedFiles []string expectedTargets int expectedPackages []string wantErr bool @@ -704,7 +664,6 @@ func TestUvParseCommand(t *testing.T) { name: "uv add simple package", args: []string{"add", "flask"}, expectedManifest: false, - expectedFiles: []string{""}, expectedTargets: 1, expectedPackages: []string{"flask"}, wantErr: false, @@ -713,7 +672,6 @@ func TestUvParseCommand(t *testing.T) { name: "uv add multiple packages", args: []string{"add", "flask", "requests"}, expectedManifest: false, - expectedFiles: []string{""}, expectedTargets: 2, expectedPackages: []string{ "flask", @@ -725,7 +683,6 @@ func TestUvParseCommand(t *testing.T) { name: "uv pip install simple package", args: []string{"pip", "install", "fastapi"}, expectedManifest: false, - expectedFiles: []string{""}, expectedTargets: 2, expectedPackages: []string{"fastapi"}, wantErr: false, @@ -734,7 +691,6 @@ func TestUvParseCommand(t *testing.T) { name: "uv pip install multiple packages", args: []string{"pip", "install", "flask", "requests"}, expectedManifest: false, - expectedFiles: []string{""}, expectedTargets: 2, expectedPackages: []string{ "flask", @@ -746,7 +702,6 @@ func TestUvParseCommand(t *testing.T) { name: "uv pip install from manifest file", args: []string{"pip", "install", "-r", "requirements.txt"}, expectedManifest: true, - expectedFiles: []string{"requirements.txt"}, expectedTargets: 0, expectedPackages: []string{}, wantErr: false, @@ -755,7 +710,6 @@ func TestUvParseCommand(t *testing.T) { name: "uv pip install from multiple manifest files", args: []string{"pip", "install", "-r", "requirements.txt", "-r", "dev-requirements.txt"}, expectedManifest: true, - expectedFiles: []string{"requirements.txt", "dev-requirements.txt"}, expectedTargets: 0, expectedPackages: []string{}, wantErr: false, @@ -764,7 +718,6 @@ func TestUvParseCommand(t *testing.T) { name: "uv sync", args: []string{"sync"}, expectedManifest: true, - expectedFiles: []string{"uv.lock"}, expectedTargets: 0, expectedPackages: []string{}, wantErr: false, @@ -782,9 +735,6 @@ func TestUvParseCommand(t *testing.T) { assert.Equal(t, tc.expectedManifest, result.HasManifestInstall(), "HasManifestInstall mismatch") - expectedShouldExtract := tc.expectedManifest && tc.expectedTargets == 0 - assert.Equal(t, expectedShouldExtract, result.ShouldExtractFromManifest(), "ShouldExtractFromManifest mismatch") - assert.Equal(t, len(tc.expectedPackages), len(result.InstallTargets), "Number of install targets mismatch") for i, expectedPkg := range tc.expectedPackages { diff --git a/packagemanager/pypi_uvx_executor.go b/packagemanager/pypi_uvx_executor.go index 8df4a1a..25e4db0 100644 --- a/packagemanager/pypi_uvx_executor.go +++ b/packagemanager/pypi_uvx_executor.go @@ -46,8 +46,8 @@ func (p *pypiPackageExecutor) parseUvxCommand(command Command, args []string) (* // the pipx/pip/uv executors. uv adds flags frequently; failing closed on an // unrecognized flag would break otherwise-valid uvx invocations after a uv // upgrade. The residual gap — a future value-taking flag consuming the tool - // positional and yielding no audit target — only affects non-proxy guard - // mode; the default proxy flow still intercepts every registry download. + // positional and yielding no audit target — is contained because the proxy + // flow still intercepts every registry download. flagSet.ParseErrorsAllowlist.UnknownFlags = true flagSet.SetOutput(io.Discard) diff --git a/proxy/interceptors/confirmation.go b/proxy/interceptors/confirmation.go index 3079a6c..41a0f6e 100644 --- a/proxy/interceptors/confirmation.go +++ b/proxy/interceptors/confirmation.go @@ -4,7 +4,7 @@ import ( packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" "github.com/safedep/dry/log" "github.com/safedep/pmg/analyzer" - "github.com/safedep/pmg/guard" + "github.com/safedep/pmg/packagemanager" ) // ConfirmationRequest represents a request for user confirmation on a suspicious package @@ -31,7 +31,7 @@ type ConfirmationHook struct { // // The function will exit when the confirmation channel is closed. func HandleConfirmationRequests(confirmationChan chan *ConfirmationRequest, - interaction *guard.PackageManagerGuardInteraction, hooks *ConfirmationHook) { + interaction *packagemanager.PackageManagerInteraction, hooks *ConfirmationHook) { if hooks == nil { hooks = &ConfirmationHook{} } diff --git a/proxy/interceptors/confirmation_test.go b/proxy/interceptors/confirmation_test.go index ccf4c82..26e5c16 100644 --- a/proxy/interceptors/confirmation_test.go +++ b/proxy/interceptors/confirmation_test.go @@ -6,7 +6,7 @@ import ( packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" "github.com/safedep/pmg/analyzer" - "github.com/safedep/pmg/guard" + "github.com/safedep/pmg/packagemanager" "github.com/stretchr/testify/assert" ) @@ -93,7 +93,7 @@ func TestHandleConfirmationRequests(t *testing.T) { afterCalled := false var afterConfirmedParam bool - interaction := guard.PackageManagerGuardInteraction{ + interaction := packagemanager.PackageManagerInteraction{ GetConfirmationOnMalware: func(results []*analyzer.PackageVersionAnalysisResult) (bool, error) { assert.Len(t, results, 1) return tt.confirmationResponse, tt.confirmationError @@ -150,7 +150,7 @@ func TestHandleConfirmationRequests(t *testing.T) { func TestHandleConfirmationRequests_MultipleSequential(t *testing.T) { processedPackages := []string{} - interaction := guard.PackageManagerGuardInteraction{ + interaction := packagemanager.PackageManagerInteraction{ GetConfirmationOnMalware: func(results []*analyzer.PackageVersionAnalysisResult) (bool, error) { pkgName := results[0].PackageVersion.GetPackage().GetName() processedPackages = append(processedPackages, pkgName) diff --git a/sandbox/executor/apply.go b/sandbox/executor/apply.go index bf565fa..32ff897 100644 --- a/sandbox/executor/apply.go +++ b/sandbox/executor/apply.go @@ -43,7 +43,7 @@ func WithExecutionContext(rt *sandbox.ExecutionContext) applySandboxOpt { } // ApplySandbox applies sandbox isolation to the command if sandbox mode is enabled. -// This is a helper function used by both guard and proxy flows to avoid code duplication. +// This is a helper function used by the command runner to avoid code duplication. // // This is a security sensitive operation. If sandbox is enabled via. config but not available on the platform, // it will return an error to avoid running the command without sandbox protection. diff --git a/test/proxye2e/confirm.go b/test/proxye2e/confirm.go index e48dda0..8395422 100644 --- a/test/proxye2e/confirm.go +++ b/test/proxye2e/confirm.go @@ -4,7 +4,7 @@ import ( "sync" "github.com/safedep/pmg/analyzer" - "github.com/safedep/pmg/guard" + "github.com/safedep/pmg/packagemanager" ) // ConfirmController drives the suspicious-package confirmation prompt. The @@ -46,8 +46,8 @@ func (c *ConfirmController) Prompts() [][]string { return out } -func (c *ConfirmController) interaction() *guard.PackageManagerGuardInteraction { - return &guard.PackageManagerGuardInteraction{ +func (c *ConfirmController) interaction() *packagemanager.PackageManagerInteraction { + return &packagemanager.PackageManagerInteraction{ GetConfirmationOnMalware: func(pkgs []*analyzer.PackageVersionAnalysisResult) (bool, error) { names := make([]string, 0, len(pkgs)) for _, p := range pkgs {