feat(proxy): report blocked packages on stop, exit 1 if any were blocked

pmg proxy stop now waits for the proxy process to exit, reads the final
blocked count written to the state file on shutdown, and exits non-zero
when one or more packages were blocked during the session.

This gives CI a clear failure signal from the proxy itself, separate
from the package manager's own exit code.
This commit is contained in:
Sahilb315
2026-06-23 17:07:07 +05:30
parent e19aa274f1
commit ea2c7e8242
5 changed files with 63 additions and 19 deletions
+11 -13
View File
@@ -75,19 +75,13 @@ jobs:
echo "SUCCESS: lodash installed through proxy" echo "SUCCESS: lodash installed through proxy"
cd .. && rm -rf benign-test cd .. && rm -rf benign-test
- name: Malicious package is blocked - name: Malicious package is blocked (npm exits non-zero)
run: | run: |
mkdir malicious-test && cd malicious-test mkdir malicious-test && cd malicious-test
npm init -y npm init -y
if npm --no-cache --prefer-online install safedep-test-pkg@0.1.3; then npm --no-cache --prefer-online install safedep-test-pkg@0.1.3 && exit 1 || true
echo "ERROR: safedep-test-pkg was not blocked!" test ! -d node_modules/safedep-test-pkg
exit 1 echo "SUCCESS: npm correctly failed to install safedep-test-pkg"
fi
if [ -d "node_modules/safedep-test-pkg" ]; then
echo "ERROR: safedep-test-pkg found in node_modules!"
exit 1
fi
echo "SUCCESS: safedep-test-pkg blocked by persistent proxy"
cd .. && rm -rf malicious-test cd .. && rm -rf malicious-test
- name: pip installs through proxy - name: pip installs through proxy
@@ -97,6 +91,10 @@ jobs:
python -c "import requests; print('pip ok:', requests.__version__)" python -c "import requests; print('pip ok:', requests.__version__)"
deactivate && rm -rf venv deactivate && rm -rf venv
- name: Stop proxy - name: Stop proxy (exits 1 when packages were blocked)
if: always() run: |
run: pmg proxy stop || true if pmg proxy stop; then
echo "ERROR: pmg proxy stop should have exited non-zero (packages were blocked)"
exit 1
fi
echo "SUCCESS: pmg proxy stop correctly reported blocked packages"
+7 -1
View File
@@ -106,7 +106,13 @@ func runStart(_ *cobra.Command, _ []string) error {
<-sigCh <-sigCh
close(confirmationChan) close(confirmationChan)
_ = proxystate.Remove(statePath)
// Write final blocked count before exiting so `pmg proxy stop` can read it.
// stop is responsible for removing the state file.
state.BlockedCount = stats.GetStats().BlockedCount
if werr := proxystate.Write(statePath, state); werr != nil {
log.Warnf("failed to write final proxy state: %v", werr)
}
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
+38 -2
View File
@@ -4,12 +4,20 @@ import (
"fmt" "fmt"
"os" "os"
"syscall" "syscall"
"time"
"github.com/safedep/dry/usefulerror"
"github.com/safedep/pmg/config" "github.com/safedep/pmg/config"
"github.com/safedep/pmg/errcodes"
"github.com/safedep/pmg/internal/proxystate" "github.com/safedep/pmg/internal/proxystate"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
const (
stopPollInterval = 200 * time.Millisecond
stopPollTimeout = 10 * time.Second
)
func newStopCommand() *cobra.Command { func newStopCommand() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "stop", Use: "stop",
@@ -41,8 +49,36 @@ func runStop(_ *cobra.Command, _ []string) error {
return fmt.Errorf("send SIGTERM to proxy (pid %d): %w", state.PID, err) return fmt.Errorf("send SIGTERM to proxy (pid %d): %w", state.PID, err)
} }
if _, err := fmt.Fprintf(os.Stdout, "Sent SIGTERM to PMG proxy (pid %d, addr %s)\n", state.PID, state.Addr); err != nil { // Wait for the process to exit so we can read the final blocked count it writes.
return fmt.Errorf("write stop message: %w", err) deadline := time.Now().Add(stopPollTimeout)
for time.Now().Before(deadline) {
if !state.IsRunning() {
break
}
time.Sleep(stopPollInterval)
}
// Read the final state the proxy wrote on shutdown (has BlockedCount).
final, rerr := proxystate.Read(statePath)
_ = proxystate.Remove(statePath)
if rerr != nil {
// Proxy exited but didn't write final state (e.g. crash). Treat as clean.
if _, werr := fmt.Fprintf(os.Stdout, "PMG proxy (pid %d) stopped\n", state.PID); werr != nil {
return werr
}
return nil
}
if _, werr := fmt.Fprintf(os.Stdout, "PMG proxy stopped — analyzed packages, %d blocked\n", final.BlockedCount); werr != nil {
return werr
}
if final.BlockedCount > 0 {
return usefulerror.NewUsefulError().
WithCode(errcodes.ProxyPackagesBlocked).
WithMsg(fmt.Sprintf("%d package(s) were blocked by the proxy", final.BlockedCount)).
WithHelp("Review the proxy logs for details on blocked packages")
} }
return nil return nil
+3
View File
@@ -24,6 +24,9 @@ const (
CertTrustStore = "CertTrustStore" CertTrustStore = "CertTrustStore"
UnsupportedPlatform = "UnsupportedPlatform" UnsupportedPlatform = "UnsupportedPlatform"
// Proxy error codes.
ProxyPackagesBlocked = "ProxyPackagesBlocked"
// Unknown mirrors the default code that dry/usefulerror returns for errors // Unknown mirrors the default code that dry/usefulerror returns for errors
// created without an explicit code, so unset and explicitly-unknown errors // created without an explicit code, so unset and explicitly-unknown errors
// classify identically (e.g. the bug-report hint in ui.ErrorExit). // classify identically (e.g. the bug-report hint in ui.ErrorExit).
+4 -3
View File
@@ -11,9 +11,10 @@ import (
const stateFileName = "proxy-state.json" const stateFileName = "proxy-state.json"
type State struct { type State struct {
PID int `json:"pid"` PID int `json:"pid"`
Addr string `json:"addr"` Addr string `json:"addr"`
CACertPath string `json:"ca_cert_path"` CACertPath string `json:"ca_cert_path"`
BlockedCount int `json:"blocked_count"`
} }
func StatePath(configDir string) string { func StatePath(configDir string) string {