feat: Add cloud sync event emit (#212)

* feat: Add cloud sync event emit

* fix: Linter fixes

* fix: Include malysis metadata in confirmed event

* fix: Code review fixes

* fix: Emit session complet event

* fix: Code review fixes

* chore: Add comment

* chore: Add cloud info in setup info command

* fix: Proxy flow must call install started

* fix: Code review fixes

* fix: Code review fixes

* Update internal/audit/cloud_sink.go

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Signed-off-by: Abhisek Datta <abhisek.datta@gmail.com>

* fix: Code review fixes

---------

Signed-off-by: Abhisek Datta <abhisek.datta@gmail.com>
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Abhisek Datta
2026-04-11 12:33:27 +05:30
committed by GitHub
co-authored by devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 0355a5d4fd
commit e67735c1c3
20 changed files with 981 additions and 21 deletions
+70 -4
View File
@@ -3,8 +3,13 @@ package audit
import (
"context"
"fmt"
"time"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/config"
"github.com/safedep/pmg/internal/analytics"
"github.com/safedep/pmg/usefulerror"
)
var global *auditor
@@ -17,9 +22,30 @@ func resetGlobal() {
global = nil
}
func Initialize() error {
a := newAuditor(newEventlogSink())
global = a
// Initialize sets up the audit system with an eventlog sink and, when enabled,
// a cloud sync sink.
func Initialize(cfg *config.RuntimeConfig) error {
var sinks []Sink
sinks = append(sinks, newEventlogSink())
if cfg.Config.Cloud.Enabled && !analytics.IsDisabled() {
cs, err := newCloudSink(cfg)
if err != nil {
return usefulerror.Useful().
Wrap(err).
WithCode(usefulerror.ErrCodeLifecycle).
WithHumanError("Cloud sync is enabled but failed to initialize").
WithHelp("Ensure SAFEDEP_API_KEY and SAFEDEP_TENANT_ID environment variables are set").
WithAdditionalHelp("Disable cloud sync with 'cloud.enabled: false' in config if not needed")
}
sinks = append(sinks, cs)
}
if cfg.Config.Cloud.Enabled && analytics.IsDisabled() {
log.Warnf("Cloud sync is disabled because telemetry is disabled")
}
setGlobal(newAuditor(sinks...))
return nil
}
@@ -84,11 +110,14 @@ func LogMalwareBlocked(pv *packagev1.PackageVersion, reason, analysisID, referen
}
// LogMalwareConfirmed records that the user confirmed installation of a flagged package.
func LogMalwareConfirmed(pv *packagev1.PackageVersion) {
func LogMalwareConfirmed(pv *packagev1.PackageVersion, analysisID string, isMalware, isVerified bool) {
logEvent(AuditEvent{
Type: EventTypeMalwareConfirmed,
Message: fmt.Sprintf("User confirmed installation of flagged package: %s@%s", pkgName(pv), pkgVersion(pv)),
PackageVersion: pv,
AnalysisID: analysisID,
IsMalware: isMalware,
IsVerified: isVerified,
})
if global != nil {
@@ -206,6 +235,43 @@ func LogError(message string, err error) {
logEvent(event)
}
// LogSessionComplete records the end of a PMG invocation with aggregate session stats.
func LogSessionComplete(outcome Outcome, flowType FlowType) {
if global == nil {
return
}
s := global.getSession()
if s == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
cfg := config.Get()
logEvent(AuditEvent{
Type: EventTypeSessionComplete,
Message: fmt.Sprintf("Session complete: %s", outcome),
SessionData: &SessionData{
PackageManager: s.packageManager,
FlowType: flowType,
Outcome: outcome,
TotalAnalyzed: s.totalAnalyzed,
AllowedCount: s.allowedCount,
BlockedCount: s.blockedCount,
ConfirmedCount: s.confirmedCount,
TrustedSkipped: s.trustedSkipped,
InsecureBypassed: s.insecureBypassed,
Duration: time.Since(s.startTime),
SandboxEnabled: cfg.Config.Sandbox.Enabled,
ParanoidMode: cfg.Config.Paranoid,
TransitiveEnabled: cfg.Config.Transitive,
},
})
}
func mergeDetails(base, extra map[string]interface{}) map[string]interface{} {
if base == nil {
base = make(map[string]interface{})
+87 -2
View File
@@ -7,6 +7,8 @@ import (
"time"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/safedep/pmg/config"
"github.com/safedep/pmg/internal/ui"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -146,7 +148,7 @@ func TestPublicAPISilentWhenNotInitialized(t *testing.T) {
// None of these should panic
LogMalwareBlocked(nil, "reason", "", "", false, false)
LogMalwareConfirmed(nil)
LogMalwareConfirmed(nil, "", false, false)
LogInstallAllowed(nil, 5)
LogInstallTrustedAllowed(nil)
LogInstallInsecureBypass(nil)
@@ -207,7 +209,7 @@ func TestLogMalwareConfirmedIncrementsSession(t *testing.T) {
defer resetGlobal()
a.startSession("npm", nil)
LogMalwareConfirmed(testPackageVersion("pkg", "1.0", "npm"))
LogMalwareConfirmed(testPackageVersion("pkg", "1.0", "npm"), "a-1", true, false)
sess := a.getSession()
require.NotNil(t, sess)
@@ -229,3 +231,86 @@ func TestLogInstallTrustedAllowedIncrementsSession(t *testing.T) {
assert.Equal(t, uint32(1), sess.trustedSkipped)
assert.Equal(t, uint32(1), sess.totalAnalyzed)
}
func TestLogSessionCompleteDispatchesEvent(t *testing.T) {
s := &mockSink{}
a := newAuditor(s)
setGlobal(a)
defer resetGlobal()
a.startSession("npm", []string{"install", "express"})
LogInstallAllowed(testPackageVersion("express", "4.0.0", "npm"), 1)
LogSessionComplete(OutcomeSuccess, FlowTypeGuard)
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, OutcomeSuccess, events[1].SessionData.Outcome)
assert.Equal(t, uint32(1), events[1].SessionData.AllowedCount)
}
func TestLogSessionCompleteSilentWhenNotInitialized(t *testing.T) {
resetGlobal()
// Should not panic
LogSessionComplete(OutcomeSuccess, FlowTypeGuard)
}
// TestUIOutcomesMappToAuditOutcomes ensures every ui.ExecutionOutcome has a
// corresponding audit.Outcome constant. If someone adds a new outcome to the
// UI layer without updating the audit package, this test will fail.
//
// Both lists must be kept in sync manually. If a new ui.ExecutionOutcome is
// added, add it to uiOutcomes below AND add a matching audit.Outcome constant.
// The length check catches the case where one list is updated but not the other.
func TestUIOutcomesMappToAuditOutcomes(t *testing.T) {
auditOutcomes := []Outcome{
OutcomeSuccess,
OutcomeBlocked,
OutcomeUserCancelled,
OutcomeDryRun,
OutcomeError,
OutcomeInsecureBypass,
}
uiOutcomes := []ui.ExecutionOutcome{
ui.OutcomeSuccess,
ui.OutcomeBlocked,
ui.OutcomeUserCancelled,
ui.OutcomeDryRun,
ui.OutcomeError,
ui.OutcomeInsecureBypass,
}
require.Equal(t, len(uiOutcomes), len(auditOutcomes),
"ui.ExecutionOutcome and audit.Outcome count mismatch — a new outcome was added to one but not the other")
knownOutcomes := make(map[Outcome]bool, len(auditOutcomes))
for _, o := range auditOutcomes {
knownOutcomes[o] = true
}
for _, uiOutcome := range uiOutcomes {
auditOutcome := Outcome(uiOutcome.String())
assert.True(t, knownOutcomes[auditOutcome],
"ui.ExecutionOutcome %q (String()=%q) has no matching audit.Outcome constant — add it to audit/event.go",
uiOutcome, uiOutcome.String())
}
}
func TestInitializeWithCloudDisabled(t *testing.T) {
resetGlobal()
defer resetGlobal()
cfg := config.Get()
cfg.Config.Cloud.Enabled = false
err := Initialize(cfg)
require.NoError(t, err)
require.NotNil(t, global)
// Should have exactly one sink (eventlog)
assert.Len(t, global.sinks, 1)
}
+9 -9
View File
@@ -9,15 +9,15 @@ import (
)
type session struct {
mu sync.Mutex
startTime time.Time
packageManager string
args []string
totalAnalyzed uint32
allowedCount uint32
blockedCount uint32
confirmedCount uint32
trustedSkipped uint32
mu sync.Mutex
startTime time.Time
packageManager string
args []string
totalAnalyzed uint32
allowedCount uint32
blockedCount uint32
confirmedCount uint32
trustedSkipped uint32
insecureBypassed uint32
}
+125
View File
@@ -0,0 +1,125 @@
package audit
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/safedep/dry/cloud"
"github.com/safedep/dry/cloud/endpointsync"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/config"
appVersion "github.com/safedep/pmg/internal/version"
)
type cloudSink struct {
syncClient *endpointsync.SyncClient
cloudClient *cloud.Client
invocationID string
}
func newCloudSink(cfg *config.RuntimeConfig) (*cloudSink, error) {
resolver, err := cloud.NewEnvCredentialResolver()
if err != nil {
return nil, fmt.Errorf("failed to create credential resolver: %w", err)
}
creds, err := resolver.Resolve()
if err != nil {
return nil, fmt.Errorf("failed to resolve cloud credentials: %w", err)
}
cloudClient, err := cloud.NewDataPlaneClient("pmg", creds)
if err != nil {
return nil, fmt.Errorf("failed to create data plane client: %w", err)
}
transport := endpointsync.NewGrpcTransport(cloudClient.Connection())
sink, err := newCloudSinkWithTransport(transport, cfg.Config.Cloud.EndpointID, cfg.CloudSyncDBPath())
if err != nil {
if closeErr := cloudClient.Close(); closeErr != nil {
log.Warnf("failed to close cloud client after sink init failure: %v", closeErr)
}
return nil, err
}
sink.cloudClient = cloudClient
return sink, nil
}
func newCloudSinkWithTransport(transport endpointsync.EventTransport, endpointID, walPath string) (*cloudSink, error) {
var identityOpts []endpointsync.EndpointIdentityOption
if endpointID != "" {
identityOpts = append(identityOpts, endpointsync.WithEndpointID(endpointID))
}
identity := endpointsync.NewEndpointIdentityResolver(identityOpts...)
toolVersion := appVersion.Version
if toolVersion == "" {
toolVersion = "dev"
}
syncClient, err := endpointsync.NewSyncClient("pmg", toolVersion, transport, identity,
endpointsync.WithWALPath(walPath))
if err != nil {
return nil, fmt.Errorf("failed to create sync client: %w", err)
}
invocationID, err := uuid.NewRandom()
if err != nil {
if closeErr := syncClient.Close(); closeErr != nil {
log.Warnf("failed to close sync client after invocation ID generation failure: %v", closeErr)
}
return nil, fmt.Errorf("failed to generate invocation ID: %w", err)
}
return &cloudSink{
syncClient: syncClient,
invocationID: invocationID.String(),
}, nil
}
func (s *cloudSink) Handle(ctx context.Context, event AuditEvent) error {
pmgEvents := s.translateToPmgEvents(event)
if len(pmgEvents) == 0 {
return nil
}
for _, pmgEvent := range pmgEvents {
toolEvent, err := s.syncClient.NewEvent()
if err != nil {
return fmt.Errorf("failed to create tool event: %w", err)
}
toolEvent.SetPmgEvent(pmgEvent)
toolEvent.SetInvocationId(s.invocationID)
if err := s.syncClient.Emit(ctx, toolEvent); err != nil {
if errors.Is(err, endpointsync.ErrWALFull) {
log.Warnf("Cloud sync WAL is full, dropping event: %v", err)
return nil
}
return fmt.Errorf("failed to emit cloud event: %w", err)
}
}
return nil
}
func (s *cloudSink) Close() error {
var errs []error
if s.syncClient != nil {
if err := s.syncClient.Close(); err != nil {
errs = append(errs, err)
}
}
if s.cloudClient != nil {
if err := s.cloudClient.Close(); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
+92
View File
@@ -0,0 +1,92 @@
package audit
import (
"context"
"testing"
"time"
servicev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/services/controltower/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type mockTransport struct {
requests []*servicev1.SyncEventsRequest
closed bool
}
func (m *mockTransport) Send(_ context.Context, req *servicev1.SyncEventsRequest) (*servicev1.SyncEventsResponse, error) {
m.requests = append(m.requests, req)
confirmed := make([]string, 0, len(req.GetEvents()))
for _, e := range req.GetEvents() {
confirmed = append(confirmed, e.GetEventId())
}
return &servicev1.SyncEventsResponse{ConfirmedEventIds: confirmed}, nil
}
func (m *mockTransport) Close() error {
m.closed = true
return nil
}
func TestCloudSinkEmitsTranslatableEvents(t *testing.T) {
transport := &mockTransport{}
walPath := t.TempDir() + "/test-sync.db"
sink, err := newCloudSinkWithTransport(transport, "", walPath)
require.NoError(t, err)
defer func() {
require.NoError(t, sink.Close())
}()
err = sink.Handle(context.Background(), AuditEvent{
Type: EventTypeMalwareBlocked,
Timestamp: time.Now(),
Message: "blocked malware package",
})
assert.NoError(t, err)
}
func TestCloudSinkSkipsUntranslatableEvents(t *testing.T) {
transport := &mockTransport{}
walPath := t.TempDir() + "/test-sync.db"
sink, err := newCloudSinkWithTransport(transport, "", walPath)
require.NoError(t, err)
defer func() {
require.NoError(t, sink.Close())
}()
err = sink.Handle(context.Background(), AuditEvent{
Type: EventTypeProxyHostObserved,
Timestamp: time.Now(),
Message: "observed proxy host",
Hostname: "example.com",
})
assert.NoError(t, err)
}
func TestCloudSinkEmitAndSync(t *testing.T) {
transport := &mockTransport{}
walPath := t.TempDir() + "/test-sync.db"
sink, err := newCloudSinkWithTransport(transport, "", walPath)
require.NoError(t, err)
defer func() {
require.NoError(t, sink.Close())
}()
ctx := context.Background()
err = sink.Handle(ctx, AuditEvent{
Type: EventTypeMalwareBlocked,
Timestamp: time.Now(),
Message: "blocked malware package",
})
require.NoError(t, err)
synced, err := sink.syncClient.Sync(ctx)
require.NoError(t, err)
assert.Equal(t, 1, synced)
assert.Equal(t, 1, len(transport.requests))
}
+168
View File
@@ -0,0 +1,168 @@
package audit
import (
"fmt"
controltowerv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/controltower/v1"
"google.golang.org/protobuf/types/known/durationpb"
)
func (s *cloudSink) translateToPmgEvents(event AuditEvent) []*controltowerv1.PmgEvent {
switch event.Type {
case EventTypeMalwareBlocked:
return []*controltowerv1.PmgEvent{newPackageDecisionEvent(event, controltowerv1.PmgPackageAction_PMG_PACKAGE_ACTION_BLOCKED)}
case EventTypeMalwareConfirmed:
return []*controltowerv1.PmgEvent{newPackageDecisionEvent(event, controltowerv1.PmgPackageAction_PMG_PACKAGE_ACTION_CONFIRMED)}
case EventTypeInstallInsecureBypass:
// PmgInsecureBypass is a session-level aggregate (package manager + total bypassed count),
// not a per-package event. It is emitted as part of EventTypeSessionComplete when
// the session's insecureBypassed counter is > 0.
return nil
case EventTypeSandboxOverride:
return []*controltowerv1.PmgEvent{newSandboxOverrideEvent(event)}
case EventTypeError:
return []*controltowerv1.PmgEvent{newErrorEvent(event)}
case EventTypeSessionComplete:
if event.SessionData == nil {
return nil
}
events := []*controltowerv1.PmgEvent{newSessionSummaryEvent(event.SessionData)}
if event.SessionData.InsecureBypassed > 0 {
events = append(events, newInsecureBypassFromSession(event.SessionData))
}
return events
default:
return nil
}
}
func newPackageDecisionEvent(event AuditEvent, action controltowerv1.PmgPackageAction) *controltowerv1.PmgEvent {
decision := &controltowerv1.PmgPackageDecision{}
decision.SetPackageVersion(event.PackageVersion)
decision.SetAction(action)
if event.AnalysisID != "" {
decision.SetAnalysisId(event.AnalysisID)
}
decision.SetIsMalware(event.IsMalware)
decision.SetIsVerified(event.IsVerified)
e := &controltowerv1.PmgEvent{}
e.SetEventType(controltowerv1.PmgEventType_PMG_EVENT_TYPE_PACKAGE_DECISION)
e.SetPackageDecision(decision)
return e
}
func newSandboxOverrideEvent(event AuditEvent) *controltowerv1.PmgEvent {
override := &controltowerv1.PmgSandboxOverride{}
override.SetSandboxProfile(event.ProfileName)
var flattened []string
for _, m := range event.Overrides {
for k, v := range m {
flattened = append(flattened, fmt.Sprintf("%s:%s", k, v))
}
}
override.SetOverrides(flattened)
e := &controltowerv1.PmgEvent{}
e.SetEventType(controltowerv1.PmgEventType_PMG_EVENT_TYPE_SANDBOX_OVERRIDE)
e.SetSandboxOverride(override)
return e
}
func newErrorEvent(event AuditEvent) *controltowerv1.PmgEvent {
pmgErr := &controltowerv1.PmgError{}
if event.Error != nil {
pmgErr.SetErrorType(fmt.Sprintf("%T", event.Error))
}
pmgErr.SetMessage(event.Message)
e := &controltowerv1.PmgEvent{}
e.SetEventType(controltowerv1.PmgEventType_PMG_EVENT_TYPE_ERROR)
e.SetError(pmgErr)
return e
}
func newSessionSummaryEvent(data *SessionData) *controltowerv1.PmgEvent {
summary := &controltowerv1.PmgSessionSummary{}
summary.SetPackageManager(mapPackageManager(data.PackageManager))
summary.SetFlowType(mapFlowType(data.FlowType))
summary.SetTotalAnalyzed(data.TotalAnalyzed)
summary.SetAllowedCount(data.AllowedCount)
summary.SetBlockedCount(data.BlockedCount)
summary.SetConfirmedCount(data.ConfirmedCount)
summary.SetTrustedSkipped(data.TrustedSkipped)
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{}
e.SetEventType(controltowerv1.PmgEventType_PMG_EVENT_TYPE_SESSION_SUMMARY)
e.SetSessionSummary(summary)
return e
}
func newInsecureBypassFromSession(data *SessionData) *controltowerv1.PmgEvent {
bypass := &controltowerv1.PmgInsecureBypass{}
bypass.SetPackageManager(mapPackageManager(data.PackageManager))
bypass.SetPackagesBypassed(data.InsecureBypassed)
e := &controltowerv1.PmgEvent{}
e.SetEventType(controltowerv1.PmgEventType_PMG_EVENT_TYPE_INSECURE_BYPASS)
e.SetInsecureBypass(bypass)
return e
}
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:
return controltowerv1.PmgFlowType_PMG_FLOW_TYPE_UNSPECIFIED
}
}
func mapSessionOutcome(outcome Outcome) controltowerv1.PmgSessionOutcome {
switch outcome {
case OutcomeSuccess:
return controltowerv1.PmgSessionOutcome_PMG_SESSION_OUTCOME_SUCCESS
case OutcomeBlocked:
return controltowerv1.PmgSessionOutcome_PMG_SESSION_OUTCOME_BLOCKED
case OutcomeUserCancelled:
return controltowerv1.PmgSessionOutcome_PMG_SESSION_OUTCOME_USER_CANCELLED
case OutcomeError:
return controltowerv1.PmgSessionOutcome_PMG_SESSION_OUTCOME_ERROR
case OutcomeDryRun:
return controltowerv1.PmgSessionOutcome_PMG_SESSION_OUTCOME_DRY_RUN
case OutcomeInsecureBypass:
return controltowerv1.PmgSessionOutcome_PMG_SESSION_OUTCOME_INSECURE_BYPASS
default:
return controltowerv1.PmgSessionOutcome_PMG_SESSION_OUTCOME_UNSPECIFIED
}
}
func mapPackageManager(name string) controltowerv1.PmgPackageManager {
switch name {
case "npm", "npx":
return controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_NPM
case "pnpm", "pnpx":
return controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_PNPM
case "yarn":
return controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_YARN
case "bun":
return controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_BUN
case "pip", "pip3":
return controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_PIP
case "poetry":
return controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_POETRY
case "uv":
return controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_UV
default:
return controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_UNSPECIFIED
}
}
+286
View File
@@ -0,0 +1,286 @@
package audit
import (
"errors"
"testing"
"time"
controltowerv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/controltower/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testCloudSink returns a cloudSink with no real SyncClient, suitable for testing translation.
var testSink = &cloudSink{invocationID: "test-invocation"}
func TestTranslateMalwareBlocked(t *testing.T) {
event := AuditEvent{
Type: EventTypeMalwareBlocked,
PackageVersion: testPackageVersion("evil-pkg", "1.2.3", "npm"),
AnalysisID: "analysis-123",
IsMalware: true,
IsVerified: true,
}
results := testSink.translateToPmgEvents(event)
require.Len(t, results, 1)
result := results[0]
assert.Equal(t, controltowerv1.PmgEventType_PMG_EVENT_TYPE_PACKAGE_DECISION, result.GetEventType())
require.True(t, result.HasPackageDecision())
decision := result.GetPackageDecision()
assert.Equal(t, controltowerv1.PmgPackageAction_PMG_PACKAGE_ACTION_BLOCKED, decision.GetAction())
assert.Equal(t, "analysis-123", decision.GetAnalysisId())
assert.True(t, decision.GetIsMalware())
assert.True(t, decision.GetIsVerified())
assert.NotNil(t, decision.GetPackageVersion())
}
func TestTranslateMalwareConfirmed(t *testing.T) {
event := AuditEvent{
Type: EventTypeMalwareConfirmed,
PackageVersion: testPackageVersion("suspect-pkg", "2.0.0", "npm"),
AnalysisID: "analysis-456",
IsMalware: false,
IsVerified: false,
}
results := testSink.translateToPmgEvents(event)
require.Len(t, results, 1)
result := results[0]
assert.Equal(t, controltowerv1.PmgEventType_PMG_EVENT_TYPE_PACKAGE_DECISION, result.GetEventType())
require.True(t, result.HasPackageDecision())
decision := result.GetPackageDecision()
assert.Equal(t, controltowerv1.PmgPackageAction_PMG_PACKAGE_ACTION_CONFIRMED, decision.GetAction())
assert.Equal(t, "analysis-456", decision.GetAnalysisId())
assert.False(t, decision.GetIsMalware())
assert.False(t, decision.GetIsVerified())
assert.False(t, decision.GetIsVerified())
}
func TestTranslateInsecureBypassReturnsEmpty(t *testing.T) {
event := AuditEvent{
Type: EventTypeInstallInsecureBypass,
}
results := testSink.translateToPmgEvents(event)
assert.Empty(t, results)
}
func TestTranslateSandboxOverride(t *testing.T) {
event := AuditEvent{
Type: EventTypeSandboxOverride,
ProfileName: "strict",
Overrides: []map[string]string{
{"read": "/tmp"},
{"write": "/var/log"},
},
}
results := testSink.translateToPmgEvents(event)
require.Len(t, results, 1)
result := results[0]
assert.Equal(t, controltowerv1.PmgEventType_PMG_EVENT_TYPE_SANDBOX_OVERRIDE, result.GetEventType())
require.True(t, result.HasSandboxOverride())
override := result.GetSandboxOverride()
assert.Equal(t, "strict", override.GetSandboxProfile())
assert.ElementsMatch(t, []string{"read:/tmp", "write:/var/log"}, override.GetOverrides())
}
func TestTranslateError(t *testing.T) {
event := AuditEvent{
Type: EventTypeError,
Message: "something went wrong",
Error: errors.New("connection refused"),
}
results := testSink.translateToPmgEvents(event)
require.Len(t, results, 1)
result := results[0]
assert.Equal(t, controltowerv1.PmgEventType_PMG_EVENT_TYPE_ERROR, result.GetEventType())
require.True(t, result.HasError())
pmgErr := result.GetError()
assert.Equal(t, "*errors.errorString", pmgErr.GetErrorType())
assert.Equal(t, "something went wrong", pmgErr.GetMessage())
}
func TestTranslateErrorNilError(t *testing.T) {
event := AuditEvent{
Type: EventTypeError,
Message: "unknown issue",
Error: nil,
}
results := testSink.translateToPmgEvents(event)
require.Len(t, results, 1)
result := results[0]
pmgErr := result.GetError()
assert.Equal(t, "", pmgErr.GetErrorType())
assert.Equal(t, "unknown issue", pmgErr.GetMessage())
}
func TestTranslateUnsupportedEventReturnsEmpty(t *testing.T) {
unsupported := []EventType{
EventTypeProxyHostObserved,
EventTypeDependencyResolved,
EventTypeInstallStarted,
EventTypeInstallAllowed,
EventTypeInstallTrustedAllowed,
EventTypeInstallInsecureBypass,
}
for _, et := range unsupported {
t.Run(string(et), func(t *testing.T) {
results := testSink.translateToPmgEvents(AuditEvent{Type: et})
assert.Empty(t, results)
})
}
}
func TestMapPackageManager(t *testing.T) {
tests := []struct {
name string
input string
expected controltowerv1.PmgPackageManager
}{
{"npm", "npm", controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_NPM},
{"npx", "npx", controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_NPM},
{"pnpm", "pnpm", controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_PNPM},
{"pnpx", "pnpx", controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_PNPM},
{"yarn", "yarn", controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_YARN},
{"bun", "bun", controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_BUN},
{"pip", "pip", controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_PIP},
{"pip3", "pip3", controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_PIP},
{"poetry", "poetry", controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_POETRY},
{"uv", "uv", controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_UV},
{"unknown", "cargo", controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_UNSPECIFIED},
{"empty", "", controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_UNSPECIFIED},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.expected, mapPackageManager(tc.input))
})
}
}
func TestTranslateSessionComplete(t *testing.T) {
event := AuditEvent{
Type: EventTypeSessionComplete,
SessionData: &SessionData{
PackageManager: "npm",
FlowType: FlowTypeProxy,
Outcome: OutcomeSuccess,
TotalAnalyzed: 10,
AllowedCount: 8,
BlockedCount: 1,
ConfirmedCount: 1,
TrustedSkipped: 2,
InsecureBypassed: 0,
Duration: 5 * time.Second,
SandboxEnabled: true,
ParanoidMode: false,
TransitiveEnabled: true,
},
}
results := testSink.translateToPmgEvents(event)
require.Len(t, results, 1)
summary := results[0].GetSessionSummary()
require.NotNil(t, summary)
assert.Equal(t, controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_NPM, summary.GetPackageManager())
assert.Equal(t, controltowerv1.PmgFlowType_PMG_FLOW_TYPE_PROXY, summary.GetFlowType())
assert.Equal(t, uint32(10), summary.GetTotalAnalyzed())
assert.Equal(t, uint32(8), summary.GetAllowedCount())
assert.Equal(t, uint32(1), summary.GetBlockedCount())
assert.Equal(t, uint32(1), summary.GetConfirmedCount())
assert.Equal(t, uint32(2), summary.GetTrustedSkipped())
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())
}
func TestTranslateSessionCompleteWithInsecureBypass(t *testing.T) {
event := AuditEvent{
Type: EventTypeSessionComplete,
SessionData: &SessionData{
PackageManager: "pip",
FlowType: FlowTypeGuard,
Outcome: OutcomeInsecureBypass,
InsecureBypassed: 3,
},
}
results := testSink.translateToPmgEvents(event)
require.Len(t, results, 2)
assert.Equal(t, controltowerv1.PmgEventType_PMG_EVENT_TYPE_SESSION_SUMMARY, results[0].GetEventType())
assert.Equal(t, controltowerv1.PmgEventType_PMG_EVENT_TYPE_INSECURE_BYPASS, results[1].GetEventType())
bypass := results[1].GetInsecureBypass()
require.NotNil(t, bypass)
assert.Equal(t, controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_PIP, bypass.GetPackageManager())
assert.Equal(t, uint32(3), bypass.GetPackagesBypassed())
}
func TestTranslateSessionCompleteNilSessionData(t *testing.T) {
event := AuditEvent{
Type: EventTypeSessionComplete,
SessionData: nil,
}
results := testSink.translateToPmgEvents(event)
assert.Empty(t, results)
}
func TestMapFlowType(t *testing.T) {
tests := []struct {
name string
input FlowType
expected controltowerv1.PmgFlowType
}{
{"guard", FlowTypeGuard, controltowerv1.PmgFlowType_PMG_FLOW_TYPE_GUARD},
{"proxy", FlowTypeProxy, controltowerv1.PmgFlowType_PMG_FLOW_TYPE_PROXY},
{"unknown", FlowType("other"), controltowerv1.PmgFlowType_PMG_FLOW_TYPE_UNSPECIFIED},
{"empty", FlowType(""), controltowerv1.PmgFlowType_PMG_FLOW_TYPE_UNSPECIFIED},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.expected, mapFlowType(tc.input))
})
}
}
func TestMapSessionOutcome(t *testing.T) {
tests := []struct {
name string
input Outcome
expected controltowerv1.PmgSessionOutcome
}{
{"success", OutcomeSuccess, controltowerv1.PmgSessionOutcome_PMG_SESSION_OUTCOME_SUCCESS},
{"blocked", OutcomeBlocked, controltowerv1.PmgSessionOutcome_PMG_SESSION_OUTCOME_BLOCKED},
{"user_cancelled", OutcomeUserCancelled, controltowerv1.PmgSessionOutcome_PMG_SESSION_OUTCOME_USER_CANCELLED},
{"error", OutcomeError, controltowerv1.PmgSessionOutcome_PMG_SESSION_OUTCOME_ERROR},
{"dry_run", OutcomeDryRun, controltowerv1.PmgSessionOutcome_PMG_SESSION_OUTCOME_DRY_RUN},
{"insecure_bypass", OutcomeInsecureBypass, controltowerv1.PmgSessionOutcome_PMG_SESSION_OUTCOME_INSECURE_BYPASS},
{"unknown", Outcome("other"), controltowerv1.PmgSessionOutcome_PMG_SESSION_OUTCOME_UNSPECIFIED},
{"empty", Outcome(""), controltowerv1.PmgSessionOutcome_PMG_SESSION_OUTCOME_UNSPECIFIED},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.expected, mapSessionOutcome(tc.input))
})
}
}
+40
View File
@@ -6,6 +6,43 @@ import (
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
)
// SessionData carries aggregate session statistics for session-complete events.
type SessionData struct {
PackageManager string
FlowType FlowType
Outcome Outcome
TotalAnalyzed uint32
AllowedCount uint32
BlockedCount uint32
ConfirmedCount uint32
TrustedSkipped uint32
InsecureBypassed uint32
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"
)
// Outcome describes the final result of a PMG invocation.
type Outcome string
const (
OutcomeSuccess Outcome = "success"
OutcomeBlocked Outcome = "blocked"
OutcomeUserCancelled Outcome = "user_cancelled"
OutcomeDryRun Outcome = "dry_run"
OutcomeError Outcome = "error"
OutcomeInsecureBypass Outcome = "insecure_bypass"
)
type EventType string
const (
@@ -56,4 +93,7 @@ type AuditEvent struct {
// Error context
Error error
// Session summary data — populated only for EventTypeSessionComplete
SessionData *SessionData
}
+20 -1
View File
@@ -13,6 +13,11 @@ func newEventlogSink() *eventlogSink {
}
func (s *eventlogSink) Handle(_ context.Context, event AuditEvent) error {
details := event.Details
if details == nil && event.SessionData != nil {
details = sessionDataToDetails(event.SessionData)
}
elEvent := eventlog.Event{
Timestamp: event.Timestamp,
EventType: mapEventType(event.Type),
@@ -20,11 +25,25 @@ func (s *eventlogSink) Handle(_ context.Context, event AuditEvent) error {
PackageName: pkgName(event.PackageVersion),
Version: pkgVersion(event.PackageVersion),
Ecosystem: pkgEcosystem(event.PackageVersion),
Details: event.Details,
Details: details,
}
return eventlog.LogEvent(elEvent)
}
func sessionDataToDetails(sd *SessionData) map[string]interface{} {
return map[string]interface{}{
"outcome": sd.Outcome,
"flow_type": sd.FlowType,
"package_manager": sd.PackageManager,
"total_analyzed": sd.TotalAnalyzed,
"allowed_count": sd.AllowedCount,
"blocked_count": sd.BlockedCount,
"confirmed_count": sd.ConfirmedCount,
"trusted_skipped": sd.TrustedSkipped,
"insecure_bypassed": sd.InsecureBypassed,
}
}
func (s *eventlogSink) Close() error {
return nil
}
+5
View File
@@ -9,6 +9,7 @@ import (
"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/ui"
"github.com/safedep/pmg/packagemanager"
)
@@ -110,6 +111,10 @@ func (f *commonFlow) Run(ctx context.Context, args []string, parsedCmd *packagem
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)
+14 -1
View File
@@ -14,6 +14,7 @@ import (
"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/pty"
"github.com/safedep/pmg/internal/ui"
"github.com/safedep/pmg/packagemanager"
@@ -38,7 +39,7 @@ func ProxyFlow(pm packagemanager.PackageManager, packageResolver packagemanager.
}
// Run executes the proxy-based flow
func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagemanager.ParsedCommand) error {
func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagemanager.ParsedCommand) (runErr error) {
// Check if we have a supported ecosystem else fail fast
ecosystem := f.pm.Ecosystem()
if !interceptors.IsSupported(ecosystem) {
@@ -72,6 +73,18 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema
startTime := time.Now()
audit.LogInstallStarted(f.pm.Name(), args)
defer func() {
// On early error returns (e.g. CA cert, analyzer init), reportData.Outcome
// is still the default (Success). Override to Error for these cases. Don't
// override when outcome was explicitly set (e.g. Blocked) — those paths may
// also return an error from the underlying package manager command.
if runErr != nil && reportData.Outcome == ui.OutcomeSuccess {
reportData.Outcome = ui.OutcomeError
}
audit.LogSessionComplete(audit.Outcome(reportData.Outcome.String()), audit.FlowTypeProxy)
}()
// Check if dry-run mode is enabled
if cfg.DryRun {
log.Infof("Dry-run mode: Would execute %s with experimental proxy protection", f.pm.Name())