mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
* 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>
310 lines
8.3 KiB
Go
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
|
|
}
|