From 5b0517f92afdcc2f1f340da91791a15e29467f36 Mon Sep 17 00:00:00 2001 From: Sahil Bansal Date: Thu, 12 Feb 2026 13:48:01 +0530 Subject: [PATCH] add event type & logging interceptor for unknown hosts (#157) * add event type & logging interceptor for unknown hosts * update logging * add break * rename host_observation interceptor to audit_logger * restore MITMDecider and make AuditLogger telemetry skip MITM on CONNECT --- internal/eventlog/eventlog.go | 22 +++++++++++ internal/eventlog/eventlog_test.go | 30 +++++++++++++++ internal/flows/proxy_flow.go | 10 +++-- proxy/interceptor.go | 5 +++ proxy/interceptors/audit_logger.go | 51 +++++++++++++++++++++++++ proxy/interceptors/audit_logger_test.go | 44 +++++++++++++++++++++ proxy/proxy.go | 22 +++++++++-- 7 files changed, 177 insertions(+), 7 deletions(-) create mode 100644 proxy/interceptors/audit_logger.go create mode 100644 proxy/interceptors/audit_logger_test.go diff --git a/internal/eventlog/eventlog.go b/internal/eventlog/eventlog.go index a638c7a..b50c449 100644 --- a/internal/eventlog/eventlog.go +++ b/internal/eventlog/eventlog.go @@ -24,6 +24,7 @@ const ( EventTypeInstallStarted EventType = "install_started" EventTypeDependencyResolved EventType = "dependency_resolved" EventTypeInstallInsecureBypass EventType = "install_insecure_bypass" + EventTypeProxyHostObserved EventType = "proxy_host_observed" EventTypeError EventType = "error" ) @@ -391,6 +392,27 @@ func LogInstallStarted(packageManager string, args []string) { } } +// LogProxyHostObserved logs when proxy mode observes outbound traffic to a host. +func LogProxyHostObserved(hostname, method, reason string, details map[string]interface{}) { + event := Event{ + EventType: EventTypeProxyHostObserved, + Message: fmt.Sprintf("Proxy observed outbound host: %s", hostname), + Details: map[string]interface{}{ + "hostname": hostname, + "method": method, + "reason": reason, + }, + } + + for k, v := range details { + event.Details[k] = v + } + + if err := LogEvent(event); err != nil { + log.Warnf("failed to log proxy host observed event: %s", err) + } +} + // LogError logs an error event func LogError(message string, err error) { event := Event{ diff --git a/internal/eventlog/eventlog_test.go b/internal/eventlog/eventlog_test.go index c1ddc3a..f6cbdd8 100644 --- a/internal/eventlog/eventlog_test.go +++ b/internal/eventlog/eventlog_test.go @@ -115,6 +115,36 @@ func TestLogMalwareBlocked(t *testing.T) { assert.Equal(t, "pypi", event.Ecosystem) } +func TestLogProxyHostObserved(t *testing.T) { + tmpDir := t.TempDir() + logDir := filepath.Join(tmpDir, ".pmg", "logs") + + err := reinitializeForTest(logDir) + assert.NoError(t, err, "Failed to initialize logger") + defer func() { + err := Close() + assert.NoError(t, err) + }() + + LogProxyHostObserved("example.com", "CONNECT", "connect_tunnel_no_interceptor", map[string]interface{}{ + "request_id": "abc123", + }) + + logFilePath := filepath.Join(logDir, time.Now().Format("20060102")+"-pmg.log") + data, err := os.ReadFile(logFilePath) + assert.NoError(t, err, "Failed to read log file") + + var event Event + err = json.Unmarshal(data, &event) + assert.NoError(t, err, "Failed to parse event") + + assert.Equal(t, EventTypeProxyHostObserved, event.EventType) + assert.Equal(t, "example.com", event.Details["hostname"]) + assert.Equal(t, "CONNECT", event.Details["method"]) + assert.Equal(t, "connect_tunnel_no_interceptor", event.Details["reason"]) + assert.Equal(t, "abc123", event.Details["request_id"]) +} + func TestInitializeWithFile(t *testing.T) { // Create a temporary directory for testing tmpDir := t.TempDir() diff --git a/internal/flows/proxy_flow.go b/internal/flows/proxy_flow.go index 9365e3e..9f186d1 100644 --- a/internal/flows/proxy_flow.go +++ b/internal/flows/proxy_flow.go @@ -139,7 +139,10 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema log.Debugf("Created %s interceptor for ecosystem %s", interceptor.Name(), ecosystem.String()) // Create and start proxy server - proxyServer, proxyAddr, err := f.createAndStartProxyServer(certMgr, interceptor) + proxyServer, proxyAddr, err := f.createAndStartProxyServer(certMgr, []proxy.Interceptor{ + interceptor, + interceptors.NewAuditLoggerInterceptor(), + }) if err != nil { return fmt.Errorf("failed to start proxy server: %w", err) } @@ -247,13 +250,13 @@ func (f *proxyFlow) createAnalyzer() (analyzer.PackageVersionAnalyzer, error) { // createAndStartProxyServer creates and starts the proxy server with the given interceptor func (f *proxyFlow) createAndStartProxyServer( certMgr certmanager.CertificateManager, - interceptor proxy.Interceptor, + interceptorsList []proxy.Interceptor, ) (proxy.ProxyServer, string, error) { proxyConfig := &proxy.ProxyConfig{ ListenAddr: "127.0.0.1:0", CertManager: certMgr, EnableMITM: true, - Interceptors: []proxy.Interceptor{interceptor}, + Interceptors: interceptorsList, ConnectTimeout: 30 * time.Second, RequestTimeout: 5 * time.Minute, } @@ -280,6 +283,7 @@ func (f *proxyFlow) setupEnvForProxy(proxyAddr, caCertPath string) []string { env := os.Environ() env = append(env, + "NODE_USE_ENV_PROXY=1", fmt.Sprintf("HTTP_PROXY=%s", proxyURL), fmt.Sprintf("HTTPS_PROXY=%s", proxyURL), fmt.Sprintf("NODE_EXTRA_CA_CERTS=%s", caCertPath), diff --git a/proxy/interceptor.go b/proxy/interceptor.go index 0b3dc4f..f377576 100644 --- a/proxy/interceptor.go +++ b/proxy/interceptor.go @@ -76,3 +76,8 @@ type Interceptor interface { // Called for each request matching ShouldIntercept HandleRequest(ctx *RequestContext) (*InterceptorResponse, error) } + +// MITMDecider is optional; implement to control whether CONNECT requests are MITM’d. +type MITMDecider interface { + ShouldMITM(ctx *RequestContext) bool +} diff --git a/proxy/interceptors/audit_logger.go b/proxy/interceptors/audit_logger.go new file mode 100644 index 0000000..0b24a65 --- /dev/null +++ b/proxy/interceptors/audit_logger.go @@ -0,0 +1,51 @@ +package interceptors + +import ( + "github.com/safedep/pmg/internal/eventlog" + "github.com/safedep/pmg/proxy" +) + +// AuditLoggerInterceptor logs unknown outbound hosts observed by proxy mode. +// It is passive telemetry only and never blocks or mutates requests. +type AuditLoggerInterceptor struct{} + +var _ proxy.Interceptor = (*AuditLoggerInterceptor)(nil) +var _ proxy.MITMDecider = (*AuditLoggerInterceptor)(nil) + +func NewAuditLoggerInterceptor() *AuditLoggerInterceptor { + return &AuditLoggerInterceptor{} +} + +func (i *AuditLoggerInterceptor) Name() string { + return "audit-logger-interceptor" +} + +// ShouldIntercept is always true so we can observe all proxied traffic. +func (i *AuditLoggerInterceptor) ShouldIntercept(_ *proxy.RequestContext) bool { + return true +} + +// ShouldMITM is false because this interceptor is telemetry-only. +func (i *AuditLoggerInterceptor) ShouldMITM(_ *proxy.RequestContext) bool { + return false +} + +func (i *AuditLoggerInterceptor) HandleRequest(ctx *proxy.RequestContext) (*proxy.InterceptorResponse, error) { + if ctx == nil || ctx.Hostname == "" { + return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil + } + + if i.isKnownRegistryHost(ctx.Hostname) { + return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil + } + + eventlog.LogProxyHostObserved(ctx.Hostname, ctx.Method, "audit_logger_interceptor", map[string]interface{}{ + "request_id": ctx.RequestID, + }) + + return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil +} + +func (i *AuditLoggerInterceptor) isKnownRegistryHost(hostname string) bool { + return npmRegistryDomains.ContainsHostname(hostname) || pypiRegistryDomains.ContainsHostname(hostname) +} diff --git a/proxy/interceptors/audit_logger_test.go b/proxy/interceptors/audit_logger_test.go new file mode 100644 index 0000000..c6cb046 --- /dev/null +++ b/proxy/interceptors/audit_logger_test.go @@ -0,0 +1,44 @@ +package interceptors + +import ( + "net/http" + "testing" + + "github.com/safedep/pmg/proxy" + "github.com/stretchr/testify/assert" +) + +func TestAuditLoggerInterceptor_Behavior(t *testing.T) { + i := NewAuditLoggerInterceptor() + + assert.Equal(t, "audit-logger-interceptor", i.Name()) + assert.True(t, i.ShouldIntercept(nil)) + assert.False(t, i.ShouldMITM(nil)) +} + +func TestAuditLoggerInterceptor_KnownRegistryHost(t *testing.T) { + i := NewAuditLoggerInterceptor() + + resp, err := i.HandleRequest(&proxy.RequestContext{ + Hostname: "registry.npmjs.org", + Method: http.MethodConnect, + }) + + assert.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, proxy.ActionAllow, resp.Action) +} + +func TestAuditLoggerInterceptor_UnknownHost(t *testing.T) { + i := NewAuditLoggerInterceptor() + + resp, err := i.HandleRequest(&proxy.RequestContext{ + Hostname: "unknown.example.test", + Method: http.MethodConnect, + RequestID: "req-unknown", + }) + + assert.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, proxy.ActionAllow, resp.Action) +} diff --git a/proxy/proxy.go b/proxy/proxy.go index 6e6ba96..64faaa4 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -212,11 +212,25 @@ func (ps *proxyServer) configureMITM() { ps.mu.RLock() shouldMITM := false for _, interceptor := range ps.interceptors { - if interceptor.ShouldIntercept(reqCtx) { - shouldMITM = true - log.Debugf("[%s] Interceptor %s will handle %s", reqCtx.RequestID, interceptor.Name(), host) - break + if !interceptor.ShouldIntercept(reqCtx) { + continue } + + mitm := true + if decider, ok := interceptor.(MITMDecider); ok { + mitm = decider.ShouldMITM(reqCtx) + } + + if !mitm { + // Allow non-MITM interceptors (e.g., telemetry) to observe CONNECT traffic. + if _, err := interceptor.HandleRequest(reqCtx); err != nil { + log.Errorf("[%s] Interceptor %s error on CONNECT: %v", reqCtx.RequestID, interceptor.Name(), err) + } + continue + } + + shouldMITM = true + log.Debugf("[%s] Interceptor %s will handle %s", reqCtx.RequestID, interceptor.Name(), host) } ps.mu.RUnlock()