mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
feat: Add audit system with sink based dispatcher (#211)
* chore: Dependency update * feat: Add audit system with eventlog as a sink * fix: Linter fixes * fix: Code review fixes
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
|
||||
)
|
||||
|
||||
var global *auditor
|
||||
|
||||
func setGlobal(a *auditor) {
|
||||
global = a
|
||||
}
|
||||
|
||||
func resetGlobal() {
|
||||
global = nil
|
||||
}
|
||||
|
||||
func Initialize() error {
|
||||
a := newAuditor(newEventlogSink())
|
||||
global = a
|
||||
return nil
|
||||
}
|
||||
|
||||
func Close() error {
|
||||
if global == nil {
|
||||
return nil
|
||||
}
|
||||
return global.close()
|
||||
}
|
||||
|
||||
func logEvent(event AuditEvent) {
|
||||
if global == nil {
|
||||
return
|
||||
}
|
||||
global.dispatch(context.Background(), event)
|
||||
}
|
||||
|
||||
func pkgName(pv *packagev1.PackageVersion) string {
|
||||
if pv != nil {
|
||||
if pkg := pv.GetPackage(); pkg != nil {
|
||||
return pkg.GetName()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pkgVersion(pv *packagev1.PackageVersion) string {
|
||||
if pv != nil {
|
||||
return pv.GetVersion()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pkgEcosystem(pv *packagev1.PackageVersion) string {
|
||||
if pv != nil {
|
||||
if pkg := pv.GetPackage(); pkg != nil {
|
||||
return pkg.GetEcosystem().String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// LogMalwareBlocked records that a package was blocked due to malware detection.
|
||||
func LogMalwareBlocked(pv *packagev1.PackageVersion, reason, analysisID, referenceURL string, isMalware, isVerified bool) {
|
||||
logEvent(AuditEvent{
|
||||
Type: EventTypeMalwareBlocked,
|
||||
Message: fmt.Sprintf("Blocked installation of malicious package: %s@%s", pkgName(pv), pkgVersion(pv)),
|
||||
PackageVersion: pv,
|
||||
AnalysisID: analysisID,
|
||||
IsMalware: isMalware,
|
||||
IsVerified: isVerified,
|
||||
Details: map[string]interface{}{
|
||||
"reason": reason,
|
||||
"analysis_id": analysisID,
|
||||
"reference_url": referenceURL,
|
||||
},
|
||||
})
|
||||
|
||||
if global != nil {
|
||||
global.recordBlocked()
|
||||
}
|
||||
}
|
||||
|
||||
// LogMalwareConfirmed records that the user confirmed installation of a flagged package.
|
||||
func LogMalwareConfirmed(pv *packagev1.PackageVersion) {
|
||||
logEvent(AuditEvent{
|
||||
Type: EventTypeMalwareConfirmed,
|
||||
Message: fmt.Sprintf("User confirmed installation of flagged package: %s@%s", pkgName(pv), pkgVersion(pv)),
|
||||
PackageVersion: pv,
|
||||
})
|
||||
|
||||
if global != nil {
|
||||
global.recordConfirmed()
|
||||
}
|
||||
}
|
||||
|
||||
// LogInstallAllowed records that a package passed security checks and installation was permitted.
|
||||
func LogInstallAllowed(pv *packagev1.PackageVersion, packageCount int) {
|
||||
logEvent(AuditEvent{
|
||||
Type: EventTypeInstallAllowed,
|
||||
Message: fmt.Sprintf("Installation allowed for %s@%s (%d packages analyzed)", pkgName(pv), pkgVersion(pv), packageCount),
|
||||
PackageVersion: pv,
|
||||
Details: map[string]interface{}{
|
||||
"packages_analyzed": packageCount,
|
||||
},
|
||||
PackageCount: packageCount,
|
||||
})
|
||||
|
||||
if global != nil {
|
||||
global.recordAllowed()
|
||||
}
|
||||
}
|
||||
|
||||
// LogInstallTrustedAllowed records that a trusted package skipped security analysis.
|
||||
func LogInstallTrustedAllowed(pv *packagev1.PackageVersion) {
|
||||
logEvent(AuditEvent{
|
||||
Type: EventTypeInstallTrustedAllowed,
|
||||
Message: fmt.Sprintf("Installation allowed for trusted package: %s@%s", pkgName(pv), pkgVersion(pv)),
|
||||
PackageVersion: pv,
|
||||
})
|
||||
|
||||
if global != nil {
|
||||
global.recordTrustedSkipped()
|
||||
}
|
||||
}
|
||||
|
||||
// LogInstallInsecureBypass records that a package bypassed security analysis due to insecure mode.
|
||||
func LogInstallInsecureBypass(pv *packagev1.PackageVersion) {
|
||||
logEvent(AuditEvent{
|
||||
Type: EventTypeInstallInsecureBypass,
|
||||
Message: fmt.Sprintf("Installation bypassed analysis due to insecure installation mode: %s@%s", pkgName(pv), pkgVersion(pv)),
|
||||
PackageVersion: pv,
|
||||
})
|
||||
|
||||
if global != nil {
|
||||
global.recordInsecureBypassed()
|
||||
}
|
||||
}
|
||||
|
||||
// LogInstallStarted records the start of a package installation session.
|
||||
func LogInstallStarted(packageManager string, args []string) {
|
||||
logEvent(AuditEvent{
|
||||
Type: EventTypeInstallStarted,
|
||||
Message: fmt.Sprintf("Starting package installation with %s", packageManager),
|
||||
Details: map[string]interface{}{
|
||||
"package_manager": packageManager,
|
||||
"arguments": args,
|
||||
},
|
||||
PackageManager: packageManager,
|
||||
Args: args,
|
||||
})
|
||||
|
||||
if global != nil {
|
||||
global.startSession(packageManager, args)
|
||||
}
|
||||
}
|
||||
|
||||
// LogProxyHostObserved records an outbound host observed by the proxy that is not a known registry.
|
||||
func LogProxyHostObserved(hostname, method, reason string, details map[string]interface{}) {
|
||||
base := map[string]interface{}{
|
||||
"hostname": hostname,
|
||||
"method": method,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
logEvent(AuditEvent{
|
||||
Type: EventTypeProxyHostObserved,
|
||||
Message: fmt.Sprintf("Proxy observed outbound host: %s", hostname),
|
||||
Details: mergeDetails(base, details),
|
||||
Hostname: hostname,
|
||||
Method: method,
|
||||
Reason: reason,
|
||||
})
|
||||
}
|
||||
|
||||
// LogSandboxOverride records that runtime sandbox policy overrides were applied.
|
||||
func LogSandboxOverride(sandboxProfile string, overrides []map[string]string) {
|
||||
logEvent(AuditEvent{
|
||||
Type: EventTypeSandboxOverride,
|
||||
Message: fmt.Sprintf("Sandbox runtime overrides applied (%d rules)", len(overrides)),
|
||||
Details: map[string]interface{}{
|
||||
"sandbox_profile": sandboxProfile,
|
||||
"sandbox_runtime_overrides": overrides,
|
||||
},
|
||||
ProfileName: sandboxProfile,
|
||||
Overrides: overrides,
|
||||
})
|
||||
}
|
||||
|
||||
// LogError records a significant error during PMG operation.
|
||||
func LogError(message string, err error) {
|
||||
event := AuditEvent{
|
||||
Type: EventTypeError,
|
||||
Message: message,
|
||||
Error: err,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
event.Details = map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
logEvent(event)
|
||||
}
|
||||
|
||||
func mergeDetails(base, extra map[string]interface{}) map[string]interface{} {
|
||||
if base == nil {
|
||||
base = make(map[string]interface{})
|
||||
}
|
||||
for k, v := range extra {
|
||||
base[k] = v
|
||||
}
|
||||
return base
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type mockSink struct {
|
||||
mu sync.Mutex
|
||||
events []AuditEvent
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (m *mockSink) Handle(_ context.Context, event AuditEvent) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.events = append(m.events, event)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockSink) Close() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockSink) getEvents() []AuditEvent {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
cp := make([]AuditEvent, len(m.events))
|
||||
copy(cp, m.events)
|
||||
return cp
|
||||
}
|
||||
|
||||
func testPackageVersion(name, version, ecosystem string) *packagev1.PackageVersion {
|
||||
eco := packagev1.Ecosystem_ECOSYSTEM_UNSPECIFIED
|
||||
switch ecosystem {
|
||||
case "npm":
|
||||
eco = packagev1.Ecosystem_ECOSYSTEM_NPM
|
||||
case "pypi":
|
||||
eco = packagev1.Ecosystem_ECOSYSTEM_PYPI
|
||||
}
|
||||
return &packagev1.PackageVersion{
|
||||
Package: &packagev1.Package{
|
||||
Name: name,
|
||||
Ecosystem: eco,
|
||||
},
|
||||
Version: version,
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditorDispatchesToAllSinks(t *testing.T) {
|
||||
s1 := &mockSink{}
|
||||
s2 := &mockSink{}
|
||||
a := newAuditor(s1, s2)
|
||||
|
||||
event := AuditEvent{Type: EventTypeMalwareBlocked, Message: "test"}
|
||||
a.dispatch(context.Background(), event)
|
||||
|
||||
assert.Len(t, s1.getEvents(), 1)
|
||||
assert.Len(t, s2.getEvents(), 1)
|
||||
assert.Equal(t, EventTypeMalwareBlocked, s1.getEvents()[0].Type)
|
||||
assert.Equal(t, EventTypeMalwareBlocked, s2.getEvents()[0].Type)
|
||||
}
|
||||
|
||||
func TestAuditorSetsTimestamp(t *testing.T) {
|
||||
s := &mockSink{}
|
||||
a := newAuditor(s)
|
||||
|
||||
before := time.Now()
|
||||
a.dispatch(context.Background(), AuditEvent{Type: EventTypeError})
|
||||
after := time.Now()
|
||||
|
||||
events := s.getEvents()
|
||||
require.Len(t, events, 1)
|
||||
assert.False(t, events[0].Timestamp.IsZero())
|
||||
assert.True(t, !events[0].Timestamp.Before(before))
|
||||
assert.True(t, !events[0].Timestamp.After(after))
|
||||
}
|
||||
|
||||
func TestAuditorCloseClosesAllSinks(t *testing.T) {
|
||||
s1 := &mockSink{}
|
||||
s2 := &mockSink{}
|
||||
a := newAuditor(s1, s2)
|
||||
|
||||
err := a.close()
|
||||
require.NoError(t, err)
|
||||
assert.True(t, s1.closed)
|
||||
assert.True(t, s2.closed)
|
||||
}
|
||||
|
||||
func TestAuditorSessionTracking(t *testing.T) {
|
||||
a := newAuditor()
|
||||
|
||||
// No session yet — record calls are no-ops
|
||||
a.recordAllowed()
|
||||
assert.Nil(t, a.getSession())
|
||||
|
||||
a.startSession("npm", []string{"install", "lodash"})
|
||||
s := a.getSession()
|
||||
require.NotNil(t, s)
|
||||
assert.Equal(t, "npm", s.packageManager)
|
||||
assert.Equal(t, []string{"install", "lodash"}, s.args)
|
||||
|
||||
a.recordAllowed()
|
||||
a.recordBlocked()
|
||||
a.recordConfirmed()
|
||||
a.recordTrustedSkipped()
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
assert.Equal(t, uint32(3), s.totalAnalyzed) // confirmed doesn't count — LogInstallAllowed does
|
||||
assert.Equal(t, uint32(1), s.allowedCount)
|
||||
assert.Equal(t, uint32(1), s.blockedCount)
|
||||
assert.Equal(t, uint32(1), s.confirmedCount)
|
||||
assert.Equal(t, uint32(1), s.trustedSkipped)
|
||||
}
|
||||
|
||||
func TestPublicAPIDispatchesToSinks(t *testing.T) {
|
||||
s := &mockSink{}
|
||||
a := newAuditor(s)
|
||||
setGlobal(a)
|
||||
defer resetGlobal()
|
||||
|
||||
pv := testPackageVersion("evil", "1.0.0", "npm")
|
||||
LogMalwareBlocked(pv, "malware", "analysis-1", "https://ref", true, false)
|
||||
|
||||
events := s.getEvents()
|
||||
require.Len(t, events, 1)
|
||||
assert.Equal(t, EventTypeMalwareBlocked, events[0].Type)
|
||||
assert.Equal(t, pv, events[0].PackageVersion)
|
||||
assert.Equal(t, "malware", events[0].Details["reason"])
|
||||
assert.Equal(t, "analysis-1", events[0].AnalysisID)
|
||||
assert.Equal(t, true, events[0].IsMalware)
|
||||
}
|
||||
|
||||
func TestPublicAPISilentWhenNotInitialized(t *testing.T) {
|
||||
resetGlobal()
|
||||
|
||||
// None of these should panic
|
||||
LogMalwareBlocked(nil, "reason", "", "", false, false)
|
||||
LogMalwareConfirmed(nil)
|
||||
LogInstallAllowed(nil, 5)
|
||||
LogInstallTrustedAllowed(nil)
|
||||
LogInstallInsecureBypass(nil)
|
||||
LogInstallStarted("npm", []string{"install"})
|
||||
LogProxyHostObserved("host", "GET", "reason", nil)
|
||||
LogSandboxOverride("profile", nil)
|
||||
LogError("err", nil)
|
||||
}
|
||||
|
||||
func TestLogInstallStartedInitializesSession(t *testing.T) {
|
||||
s := &mockSink{}
|
||||
a := newAuditor(s)
|
||||
setGlobal(a)
|
||||
defer resetGlobal()
|
||||
|
||||
LogInstallStarted("pip", []string{"install", "requests"})
|
||||
|
||||
sess := a.getSession()
|
||||
require.NotNil(t, sess)
|
||||
assert.Equal(t, "pip", sess.packageManager)
|
||||
assert.Equal(t, []string{"install", "requests"}, sess.args)
|
||||
}
|
||||
|
||||
func TestLogInstallAllowedIncrementsSession(t *testing.T) {
|
||||
s := &mockSink{}
|
||||
a := newAuditor(s)
|
||||
setGlobal(a)
|
||||
defer resetGlobal()
|
||||
|
||||
a.startSession("npm", nil)
|
||||
LogInstallAllowed(testPackageVersion("pkg", "1.0", "npm"), 3)
|
||||
|
||||
sess := a.getSession()
|
||||
require.NotNil(t, sess)
|
||||
assert.Equal(t, uint32(1), sess.allowedCount)
|
||||
assert.Equal(t, uint32(1), sess.totalAnalyzed)
|
||||
}
|
||||
|
||||
func TestLogMalwareBlockedIncrementsSession(t *testing.T) {
|
||||
s := &mockSink{}
|
||||
a := newAuditor(s)
|
||||
setGlobal(a)
|
||||
defer resetGlobal()
|
||||
|
||||
a.startSession("npm", nil)
|
||||
LogMalwareBlocked(testPackageVersion("evil", "1.0", "npm"), "bad", "", "", true, false)
|
||||
|
||||
sess := a.getSession()
|
||||
require.NotNil(t, sess)
|
||||
assert.Equal(t, uint32(1), sess.blockedCount)
|
||||
assert.Equal(t, uint32(1), sess.totalAnalyzed)
|
||||
}
|
||||
|
||||
func TestLogMalwareConfirmedIncrementsSession(t *testing.T) {
|
||||
s := &mockSink{}
|
||||
a := newAuditor(s)
|
||||
setGlobal(a)
|
||||
defer resetGlobal()
|
||||
|
||||
a.startSession("npm", nil)
|
||||
LogMalwareConfirmed(testPackageVersion("pkg", "1.0", "npm"))
|
||||
|
||||
sess := a.getSession()
|
||||
require.NotNil(t, sess)
|
||||
assert.Equal(t, uint32(1), sess.confirmedCount)
|
||||
assert.Equal(t, uint32(0), sess.totalAnalyzed) // confirmed doesn't increment — LogInstallAllowed does
|
||||
}
|
||||
|
||||
func TestLogInstallTrustedAllowedIncrementsSession(t *testing.T) {
|
||||
s := &mockSink{}
|
||||
a := newAuditor(s)
|
||||
setGlobal(a)
|
||||
defer resetGlobal()
|
||||
|
||||
a.startSession("npm", nil)
|
||||
LogInstallTrustedAllowed(testPackageVersion("pkg", "1.0", "npm"))
|
||||
|
||||
sess := a.getSession()
|
||||
require.NotNil(t, sess)
|
||||
assert.Equal(t, uint32(1), sess.trustedSkipped)
|
||||
assert.Equal(t, uint32(1), sess.totalAnalyzed)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/safedep/dry/log"
|
||||
)
|
||||
|
||||
type session struct {
|
||||
mu sync.Mutex
|
||||
startTime time.Time
|
||||
packageManager string
|
||||
args []string
|
||||
totalAnalyzed uint32
|
||||
allowedCount uint32
|
||||
blockedCount uint32
|
||||
confirmedCount uint32
|
||||
trustedSkipped uint32
|
||||
insecureBypassed uint32
|
||||
}
|
||||
|
||||
type auditor struct {
|
||||
sinks []Sink
|
||||
session *session
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func newAuditor(sinks ...Sink) *auditor {
|
||||
return &auditor{sinks: sinks}
|
||||
}
|
||||
|
||||
func (a *auditor) dispatch(ctx context.Context, event AuditEvent) {
|
||||
if event.Timestamp.IsZero() {
|
||||
event.Timestamp = time.Now()
|
||||
}
|
||||
|
||||
for _, s := range a.sinks {
|
||||
if err := s.Handle(ctx, event); err != nil {
|
||||
log.Warnf("audit sink error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *auditor) close() error {
|
||||
var firstErr error
|
||||
for _, s := range a.sinks {
|
||||
if err := s.Close(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func (a *auditor) startSession(packageManager string, args []string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.session = &session{
|
||||
startTime: time.Now(),
|
||||
packageManager: packageManager,
|
||||
args: args,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *auditor) getSession() *session {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.session
|
||||
}
|
||||
|
||||
func (a *auditor) recordAllowed() {
|
||||
s := a.getSession()
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.allowedCount++
|
||||
s.totalAnalyzed++
|
||||
}
|
||||
|
||||
func (a *auditor) recordBlocked() {
|
||||
s := a.getSession()
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.blockedCount++
|
||||
s.totalAnalyzed++
|
||||
}
|
||||
|
||||
// recordConfirmed tracks that a user confirmed a suspicious package. It does not
|
||||
// increment totalAnalyzed because the subsequent LogInstallAllowed call for the
|
||||
// same package already does that.
|
||||
func (a *auditor) recordConfirmed() {
|
||||
s := a.getSession()
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.confirmedCount++
|
||||
}
|
||||
|
||||
func (a *auditor) recordTrustedSkipped() {
|
||||
s := a.getSession()
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.trustedSkipped++
|
||||
s.totalAnalyzed++
|
||||
}
|
||||
|
||||
func (a *auditor) recordInsecureBypassed() {
|
||||
s := a.getSession()
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.insecureBypassed++
|
||||
s.totalAnalyzed++
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
|
||||
)
|
||||
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventTypeMalwareBlocked EventType = "malware_blocked"
|
||||
EventTypeMalwareConfirmed EventType = "malware_confirmed"
|
||||
EventTypeInstallAllowed EventType = "install_allowed"
|
||||
EventTypeInstallTrustedAllowed EventType = "install_trusted_allowed"
|
||||
EventTypeInstallStarted EventType = "install_started"
|
||||
EventTypeDependencyResolved EventType = "dependency_resolved"
|
||||
EventTypeInstallInsecureBypass EventType = "install_insecure_bypass"
|
||||
EventTypeProxyHostObserved EventType = "proxy_host_observed"
|
||||
EventTypeSandboxOverride EventType = "sandbox_override"
|
||||
EventTypeError EventType = "error"
|
||||
EventTypeSessionComplete EventType = "session_complete"
|
||||
)
|
||||
|
||||
// AuditEvent is the canonical audit event for PMG. It carries all data any
|
||||
// sink might need. Fields are zero-valued when not applicable to the event type.
|
||||
type AuditEvent struct {
|
||||
Type EventType
|
||||
Timestamp time.Time
|
||||
Message string
|
||||
|
||||
// Typed package version — populated for package-level events.
|
||||
PackageVersion *packagev1.PackageVersion
|
||||
|
||||
// Analysis context
|
||||
AnalysisID string
|
||||
IsMalware bool
|
||||
IsVerified bool
|
||||
|
||||
// Freeform details for backward-compatible eventlog output.
|
||||
Details map[string]interface{}
|
||||
|
||||
// Install context
|
||||
PackageManager string
|
||||
Args []string
|
||||
PackageCount int
|
||||
|
||||
// Sandbox context
|
||||
ProfileName string
|
||||
Overrides []map[string]string
|
||||
|
||||
// Proxy context
|
||||
Hostname string
|
||||
Method string
|
||||
Reason string
|
||||
|
||||
// Error context
|
||||
Error error
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/safedep/pmg/internal/eventlog"
|
||||
)
|
||||
|
||||
type eventlogSink struct{}
|
||||
|
||||
func newEventlogSink() *eventlogSink {
|
||||
return &eventlogSink{}
|
||||
}
|
||||
|
||||
func (s *eventlogSink) Handle(_ context.Context, event AuditEvent) error {
|
||||
elEvent := eventlog.Event{
|
||||
Timestamp: event.Timestamp,
|
||||
EventType: mapEventType(event.Type),
|
||||
Message: event.Message,
|
||||
PackageName: pkgName(event.PackageVersion),
|
||||
Version: pkgVersion(event.PackageVersion),
|
||||
Ecosystem: pkgEcosystem(event.PackageVersion),
|
||||
Details: event.Details,
|
||||
}
|
||||
return eventlog.LogEvent(elEvent)
|
||||
}
|
||||
|
||||
func (s *eventlogSink) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapEventType(t EventType) eventlog.EventType {
|
||||
switch t {
|
||||
case EventTypeMalwareBlocked:
|
||||
return eventlog.EventTypeMalwareBlocked
|
||||
case EventTypeMalwareConfirmed:
|
||||
return eventlog.EventTypeMalwareConfirmed
|
||||
case EventTypeInstallAllowed:
|
||||
return eventlog.EventTypeInstallAllowed
|
||||
case EventTypeInstallTrustedAllowed:
|
||||
return eventlog.EventTypeInstallTrustedAllowed
|
||||
case EventTypeInstallStarted:
|
||||
return eventlog.EventTypeInstallStarted
|
||||
case EventTypeDependencyResolved:
|
||||
return eventlog.EventTypeDependencyResolved
|
||||
case EventTypeInstallInsecureBypass:
|
||||
return eventlog.EventTypeInstallInsecureBypass
|
||||
case EventTypeProxyHostObserved:
|
||||
return eventlog.EventTypeProxyHostObserved
|
||||
case EventTypeSandboxOverride:
|
||||
return eventlog.EventTypeSandboxOverride
|
||||
case EventTypeError:
|
||||
return eventlog.EventTypeError
|
||||
case EventTypeSessionComplete:
|
||||
return eventlog.EventType("session_complete")
|
||||
default:
|
||||
return eventlog.EventType(string(t))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/safedep/pmg/internal/eventlog"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestEventlogSinkTranslatesAllEventTypes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input EventType
|
||||
expected eventlog.EventType
|
||||
}{
|
||||
{"malware_blocked", EventTypeMalwareBlocked, eventlog.EventTypeMalwareBlocked},
|
||||
{"malware_confirmed", EventTypeMalwareConfirmed, eventlog.EventTypeMalwareConfirmed},
|
||||
{"install_allowed", EventTypeInstallAllowed, eventlog.EventTypeInstallAllowed},
|
||||
{"install_trusted_allowed", EventTypeInstallTrustedAllowed, eventlog.EventTypeInstallTrustedAllowed},
|
||||
{"install_started", EventTypeInstallStarted, eventlog.EventTypeInstallStarted},
|
||||
{"dependency_resolved", EventTypeDependencyResolved, eventlog.EventTypeDependencyResolved},
|
||||
{"install_insecure_bypass", EventTypeInstallInsecureBypass, eventlog.EventTypeInstallInsecureBypass},
|
||||
{"proxy_host_observed", EventTypeProxyHostObserved, eventlog.EventTypeProxyHostObserved},
|
||||
{"sandbox_override", EventTypeSandboxOverride, eventlog.EventTypeSandboxOverride},
|
||||
{"error", EventTypeError, eventlog.EventTypeError},
|
||||
{"session_complete", EventTypeSessionComplete, eventlog.EventType("session_complete")},
|
||||
{"unknown_type", EventType("custom_event"), eventlog.EventType("custom_event")},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := mapEventType(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package audit
|
||||
|
||||
import "context"
|
||||
|
||||
// Sink processes audit events. Implementations decide which event types they
|
||||
// care about. Handle must not block the caller for I/O-heavy operations.
|
||||
type Sink interface {
|
||||
Handle(ctx context.Context, event AuditEvent) error
|
||||
Close() error
|
||||
}
|
||||
@@ -126,7 +126,9 @@ func InitializeWithDir(logDir string) error {
|
||||
func reinitializeForTest(logDir string) error {
|
||||
// Close existing logger if any
|
||||
if globalLogger != nil {
|
||||
globalLogger.Close()
|
||||
if err := globalLogger.Close(); err != nil {
|
||||
log.Warnf("failed to close existing logger: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Reset once
|
||||
@@ -281,8 +283,6 @@ func (l *fileWithRotationLogger) IsActive() bool {
|
||||
return l.active
|
||||
}
|
||||
|
||||
// Global logging functions
|
||||
|
||||
// LogEvent logs an event using the global logger
|
||||
func LogEvent(event Event) error {
|
||||
// If logger is not initialized or not active, silently fail
|
||||
@@ -293,158 +293,6 @@ func LogEvent(event Event) error {
|
||||
return globalLogger.Log(event)
|
||||
}
|
||||
|
||||
// LogMalwareBlocked logs when malware is blocked
|
||||
func LogMalwareBlocked(packageName, version, ecosystem, reason string, details map[string]interface{}) {
|
||||
event := Event{
|
||||
EventType: EventTypeMalwareBlocked,
|
||||
Message: fmt.Sprintf("Blocked installation of malicious package: %s@%s", packageName, version),
|
||||
PackageName: packageName,
|
||||
Version: version,
|
||||
Ecosystem: ecosystem,
|
||||
Details: details,
|
||||
}
|
||||
|
||||
if details == nil {
|
||||
event.Details = make(map[string]interface{})
|
||||
}
|
||||
|
||||
event.Details["reason"] = reason
|
||||
if err := LogEvent(event); err != nil {
|
||||
log.Warnf("failed to log malware blocked event: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// LogMalwareConfirmed logs when user confirms installation despite warning
|
||||
func LogMalwareConfirmed(packageName, version, ecosystem string) {
|
||||
event := Event{
|
||||
EventType: EventTypeMalwareConfirmed,
|
||||
Message: fmt.Sprintf("User confirmed installation of flagged package: %s@%s", packageName, version),
|
||||
PackageName: packageName,
|
||||
Version: version,
|
||||
Ecosystem: ecosystem,
|
||||
}
|
||||
|
||||
if err := LogEvent(event); err != nil {
|
||||
log.Warnf("failed to log malware confirmed event: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// LogInstallAllowed logs when an installation is allowed
|
||||
func LogInstallAllowed(packageName, version, ecosystem string, packageCount int) {
|
||||
event := Event{
|
||||
EventType: EventTypeInstallAllowed,
|
||||
Message: fmt.Sprintf("Installation allowed for %s@%s (%d packages analyzed)", packageName, version, packageCount),
|
||||
PackageName: packageName,
|
||||
Version: version,
|
||||
Ecosystem: ecosystem,
|
||||
Details: map[string]interface{}{
|
||||
"packages_analyzed": packageCount,
|
||||
},
|
||||
}
|
||||
|
||||
if err := LogEvent(event); err != nil {
|
||||
log.Warnf("failed to log install allowed event: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// LogInstallTrustedAllowed logs when an installation is allowed for a trusted package
|
||||
func LogInstallTrustedAllowed(packageName, version, ecosystem string) {
|
||||
event := Event{
|
||||
EventType: EventTypeInstallTrustedAllowed,
|
||||
Message: fmt.Sprintf("Installation allowed for trusted package: %s@%s", packageName, version),
|
||||
PackageName: packageName,
|
||||
Version: version,
|
||||
Ecosystem: ecosystem,
|
||||
}
|
||||
|
||||
if err := LogEvent(event); err != nil {
|
||||
log.Warnf("failed to log install trusted allowed event: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// LogInstallInsecureBypass logs when an installation skips analysis due to insecure installation mode.
|
||||
func LogInstallInsecureBypass(packageName, version, ecosystem string) {
|
||||
event := Event{
|
||||
EventType: EventTypeInstallInsecureBypass,
|
||||
Message: fmt.Sprintf("Installation bypassed analysis due to insecure installation mode: %s@%s", packageName, version),
|
||||
PackageName: packageName,
|
||||
Version: version,
|
||||
Ecosystem: ecosystem,
|
||||
}
|
||||
|
||||
if err := LogEvent(event); err != nil {
|
||||
log.Warnf("failed to log install insecure bypass event: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// LogInstallStarted logs when an installation starts
|
||||
func LogInstallStarted(packageManager string, args []string) {
|
||||
event := Event{
|
||||
EventType: EventTypeInstallStarted,
|
||||
Message: fmt.Sprintf("Starting package installation with %s", packageManager),
|
||||
Details: map[string]interface{}{
|
||||
"package_manager": packageManager,
|
||||
"arguments": args,
|
||||
},
|
||||
}
|
||||
|
||||
if err := LogEvent(event); err != nil {
|
||||
log.Warnf("failed to log install started event: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// LogSandboxOverrides logs when runtime sandbox allow overrides are applied.
|
||||
func LogSandboxOverrides(sandboxProfile string, overrides []map[string]string) {
|
||||
event := Event{
|
||||
EventType: EventTypeSandboxOverride,
|
||||
Message: fmt.Sprintf("Sandbox runtime overrides applied (%d rules)", len(overrides)),
|
||||
Details: map[string]interface{}{
|
||||
"sandbox_profile": sandboxProfile,
|
||||
"sandbox_runtime_overrides": overrides,
|
||||
},
|
||||
}
|
||||
|
||||
if err := LogEvent(event); err != nil {
|
||||
log.Warnf("failed to log sandbox override event: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// LogError logs an error event
|
||||
func LogError(message string, err error) {
|
||||
event := Event{
|
||||
EventType: EventTypeError,
|
||||
Message: message,
|
||||
Details: map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
},
|
||||
}
|
||||
|
||||
if err := LogEvent(event); err != nil {
|
||||
log.Warnf("failed to log error event: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes the global logger
|
||||
func Close() error {
|
||||
if globalLogger != nil {
|
||||
|
||||
@@ -85,12 +85,10 @@ func TestLogEvent(t *testing.T) {
|
||||
assert.Equal(t, "evil-package", loggedEvent.PackageName)
|
||||
}
|
||||
|
||||
func TestLogMalwareBlocked(t *testing.T) {
|
||||
// Create a temporary directory for testing
|
||||
func TestLogEventMalwareBlocked(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
logDir := filepath.Join(tmpDir, ".pmg", "logs")
|
||||
|
||||
// Initialize logger
|
||||
err := reinitializeForTest(logDir)
|
||||
assert.NoError(t, err, "Failed to initialize logger")
|
||||
defer func() {
|
||||
@@ -98,10 +96,16 @@ func TestLogMalwareBlocked(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
// Log malware blocked event
|
||||
LogMalwareBlocked("malicious-pkg", "2.0.0", "pypi", "Contains known malware", nil)
|
||||
err = LogEvent(Event{
|
||||
EventType: EventTypeMalwareBlocked,
|
||||
Message: "Blocked installation of malicious package: malicious-pkg@2.0.0",
|
||||
PackageName: "malicious-pkg",
|
||||
Version: "2.0.0",
|
||||
Ecosystem: "pypi",
|
||||
Details: map[string]interface{}{"reason": "Contains known malware"},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Read and verify
|
||||
logFilePath := filepath.Join(logDir, time.Now().Format("20060102")+"-pmg.log")
|
||||
data, err := os.ReadFile(logFilePath)
|
||||
assert.NoError(t, err, "Failed to read log file")
|
||||
@@ -115,7 +119,7 @@ func TestLogMalwareBlocked(t *testing.T) {
|
||||
assert.Equal(t, "pypi", event.Ecosystem)
|
||||
}
|
||||
|
||||
func TestLogProxyHostObserved(t *testing.T) {
|
||||
func TestLogEventProxyHostObserved(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
logDir := filepath.Join(tmpDir, ".pmg", "logs")
|
||||
|
||||
@@ -126,9 +130,17 @@ func TestLogProxyHostObserved(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
LogProxyHostObserved("example.com", "CONNECT", "connect_tunnel_no_interceptor", map[string]interface{}{
|
||||
"request_id": "abc123",
|
||||
err = LogEvent(Event{
|
||||
EventType: EventTypeProxyHostObserved,
|
||||
Message: "Proxy observed outbound host: example.com",
|
||||
Details: map[string]interface{}{
|
||||
"hostname": "example.com",
|
||||
"method": "CONNECT",
|
||||
"reason": "connect_tunnel_no_interceptor",
|
||||
"request_id": "abc123",
|
||||
},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
logFilePath := filepath.Join(logDir, time.Now().Format("20060102")+"-pmg.log")
|
||||
data, err := os.ReadFile(logFilePath)
|
||||
|
||||
Reference in New Issue
Block a user