Files
pmg/internal/audit/audit.go
T
327c9c7068 feat(cooldown): respect trusted_packages in dependency cooldown (#342)
* feat(cooldown): respect trusted_packages in dependency cooldown

Trusted packages are now treated as a superset waiver that bypasses every
PMG control (malware analysis, cooldown, and any future controls). A
globally trusted package is automatically exempt from the cooldown window
and no longer needs a duplicate entry in dependency_cooldown.skip.

The skip list remains the narrower, cooldown-only waiver for packages
that must bypass the cooldown wait but still be malware-scanned.

* refactor(cooldown): tag skip reason and audit-log skipped packages

Address review feedback on #342:

- Restore cooldownSkip to a pure single-list function (SRP); the merge
  into trusted_packages now happens in a separate mergeCooldownSkip step,
  driven by the exported CooldownSkip wrapper.
- Extend CooldownSkipInfo with a CooldownSkipReason (TrustedPackage /
  CooldownSkipList) on both SkipAll and per-version entries, so callers
  can tell apart the broad waiver from the cooldown-only one. When both
  lists match the same package, trusted_packages wins.
- Add audit.LogCooldownSkipped and emit it from the npm and PyPI
  interceptors on the SkipAll path, alongside the existing info log,
  carrying the source list as the reason.

* refactor(cooldown): inline list merge, audit per-version exemptions

Address further review feedback:

- Drop the separate mergeCooldownSkip helper; cooldownSkip now writes
  into a shared *CooldownSkipInfo and is called twice from CooldownSkip
  (cooldown skip list first, trusted_packages on top so trusted entries
  override the reason on overlap).
- Audit log every exemption, not just SkipAll: a new auditCooldownSkip
  helper in proxy/interceptors/cooldown.go emits one event per match
  (package-wide or per-version), each tagged with its source list.
  LogCooldownSkipped gains a version argument for the per-version case.
- Cover the trusted_packages reason path in TestCooldownSkip.

* fix(cooldown): avoid double-auditing trusted package exemptions

auditCooldownSkip now only emits EventTypeCooldownSkipped for entries
that came from dependency_cooldown.skip. Trusted-package exemptions
already get an EventTypeInstallTrustedAllowed event at tarball-download
time (proxy/interceptors/base_registry.go), so emitting a cooldown event
for them too would double-count the same waiver.

* emit trusted and cooldown skip events to cloud

* fix tests

* refactor(cooldown): return value from collectCooldownSkip, short-circuit on trusted SkipAll

Address PR review feedback:
- Rename cooldownSkip to collectCooldownSkip and return CooldownSkipInfo
  instead of mutating an input pointer.
- Add mergeCooldownSkip to combine per-list results with trusted_packages
  taking precedence on overlap.
- CooldownSkip now consults trusted_packages first and returns immediately
  on a package-wide trusted exemption (DC skip list cannot add anything).
- Extend tests to cover disjoint pinned entries across both lists and the
  case where DC version-less subsumes a trusted pinned entry.

* fix(audit): address cooldown review feedback

* fix(cooldown): audit cooldown skips at download time with concrete version

Backend rejects PackageVersion messages without a version, and audit logs
should reflect the runtime fact (a specific version was skipped) rather
than the config rule. Move the audit emission from metadata-request
handling to download-request handling, where the concrete version is
known, and require version in LogCooldownSkipped.

* chore(audit): drop dead scope assignment in LogCooldownSkipped

* refactor(cooldown): move skip-list logic into cooldown handlers

Registry interceptors no longer compute CooldownSkip or branch on SkipAll;
they just call HandleMetadataRequest. The npm and pypi cooldown handlers
own the skip lookup, the package-wide exemption short-circuit, and (for
pypi) the canonical-name denormalization. Also align LogCooldownSkipped
with other LogXxx signatures by taking *packagev1.PackageVersion.

* fix: Simplify audit logging for dependency cooldown skip

* refactor: Simplify cooldown handling and maintain separation of concepts for trusted and DC skip packages

* fix: Code review fixes

* fix: Emit cooldown skipped audit event ONLY when an in-window version is skipped

---------

Co-authored-by: Abhisek Datta <abhisek.datta@gmail.com>
2026-06-21 18:22:15 +05:30

310 lines
8.3 KiB
Go

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"
)
var global *auditor
func setGlobal(a *auditor) {
global = a
}
func resetGlobal() {
global = nil
}
// 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 {
cs, err := newCloudSink(cfg, newCloudSinkCIResolver())
if err != nil {
log.Warnf("Cloud sync initialization failed: %v", err)
} else {
sinks = append(sinks, cs)
}
}
setGlobal(newAuditor(sinks...))
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]any{
"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, 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 {
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]any{
"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]any{
"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]any) {
base := map[string]any{
"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,
})
}
// LogDependencyCooldown records that a package was blocked by the dependency cooldown policy.
func LogDependencyCooldown(pv *packagev1.PackageVersion, publishDate time.Time, cooldownDays, daysAgo, daysLeft int) {
logEvent(AuditEvent{
Type: EventTypeDependencyCooldown,
Message: fmt.Sprintf("Package blocked by cooldown policy: %s@%s (published %d days ago, %d days remaining)", pkgName(pv), pkgVersion(pv), daysAgo, daysLeft),
PackageVersion: pv,
PublishDate: publishDate,
CooldownDays: cooldownDays,
DaysAgo: daysAgo,
DaysLeft: daysLeft,
})
if global != nil {
global.recordCooldownBlocked()
}
}
// CooldownSkipReason is the only source that produces a dependency_cooldown_skipped
// event; trusted-package exemptions surface as install_trusted_allowed instead.
const CooldownSkipReason = "dependency_cooldown.skip"
// LogCooldownSkipped records that a specific package version was exempted from
// the dependency cooldown window by the dependency_cooldown.skip list.
func LogCooldownSkipped(pv *packagev1.PackageVersion) {
logEvent(AuditEvent{
Type: EventTypeCooldownSkipped,
Message: fmt.Sprintf("Cooldown skipped for %s@%s", pkgName(pv), pkgVersion(pv)),
PackageVersion: pv,
Reason: CooldownSkipReason,
Details: map[string]any{
"reason": CooldownSkipReason,
},
})
}
// 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]any{
"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]any{
"error": 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,
CooldownBlockedCount: s.cooldownBlockedCount,
Duration: time.Since(s.startTime),
SandboxEnabled: cfg.Config.Sandbox.Enabled,
ParanoidMode: cfg.Config.Paranoid,
TransitiveEnabled: cfg.Config.Transitive,
},
})
}
func mergeDetails(base, extra map[string]any) map[string]any {
if base == nil {
base = make(map[string]any)
}
for k, v := range extra {
base[k] = v
}
return base
}