From 2d938ea381efd8ab7e57e9471386dd5dcfd1fba4 Mon Sep 17 00:00:00 2001 From: Sahil Bansal Date: Thu, 9 Jul 2026 17:49:00 +0530 Subject: [PATCH] feat: advisory message appended to block output (#362) * docs(specs): add custom block messages and package blocklist spec * docs(specs): add custom block messages and package blocklist implementation plan * feat(config): add blocked_packages list and custom block messages * feat(audit): add package_blocklist_blocked event and blocklist model * feat(proxy): block blocklisted packages in the policy gate before analysis * feat(guard): block blocklisted packages before trust skip and analysis * feat(ui): render blocklist blocks and custom messages, fix silent-mode block output * feat(proxy): append custom messages to malware and go-cooldown block bodies * test(proxye2e): cover blocklist enforcement and custom block messages * docs(specs): remove spec and plan documents * refactor: drop guard-flow blocklist enforcement and trim docs Guard mode is being deprecated; the blocklist is enforced in proxy mode only. Remove the trusted_packages mirroring references outside the docs. * refactor(config): consolidate blocklist and block message under top-level block section Replace dependency_cooldown.message, malware.message and blocked_packages with a single block section: block.message is appended to every block output regardless of which control blocked, and block.packages is the package blocklist. * fix(ui): render block.message as info note with clean spacing * fix(ui): indent wrapped continuation lines in block reasons and messages * update config template * refactor(config): replace block section with top-level advisory_message Remove the package blocklist (will be implemented as part of policies in the future) and replace block.message with an optional top-level advisory_message appended to every block output. * chore(config): move advisory_message near top-level scalar configs in template --- config/config.go | 17 ++- config/config.template.yml | 7 ++ config/config_template_test.go | 2 + config/cooldown_skip_test.go | 8 +- config/packageref.go | 49 ++++++++ config/trusted.go | 77 +++---------- config/trusted_test.go | 2 +- docs/dependency-cooldown.md | 4 + internal/flows/proxy_flow.go | 1 + internal/ui/report.go | 44 ++++++-- internal/ui/report_test.go | 138 +++++++++++++++++++++++ internal/ui/ui.go | 18 ++- proxy/interceptors/base_registry.go | 17 ++- proxy/interceptors/base_registry_test.go | 32 +++++- proxy/interceptors/go_cooldown.go | 6 +- test/proxye2e/proxye2e_test.go | 46 ++++++++ test/proxye2e/runner.go | 3 +- 17 files changed, 378 insertions(+), 93 deletions(-) create mode 100644 config/packageref.go create mode 100644 internal/ui/report_test.go diff --git a/config/config.go b/config/config.go index 15a92b3..f8ccf5d 100644 --- a/config/config.go +++ b/config/config.go @@ -10,7 +10,6 @@ import ( _ "embed" - packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" "github.com/safedep/dry/log" "github.com/safedep/dry/usefulerror" "github.com/safedep/dry/utils" @@ -82,6 +81,10 @@ type Config struct { // TrustedPackages allows for trusting a suspicious package and ignoring the suspicious behaviour for the package in future installations TrustedPackages []TrustedPackage `mapstructure:"trusted_packages"` + // AdvisoryMessage is an optional org-specific message appended to every + // block output, regardless of which control blocked the installation. + AdvisoryMessage string `mapstructure:"advisory_message"` + // SkipEventLogging allows for skipping event logging. SkipEventLogging bool `mapstructure:"skip_event_logging"` @@ -289,12 +292,7 @@ type TrustedPackage struct { Purl string `mapstructure:"purl"` Reason string `mapstructure:"reason"` - // Pre-parsed PURL components (not serialized, computed at load time) - // These fields avoid repeated PURL parsing on every IsTrustedPackage() call - parsed bool - ecosystem packagev1.Ecosystem - name string - version string + purlRef } // RuntimeConfig is the configuration that is used at runtime. It contains static configuration @@ -468,6 +466,7 @@ func DefaultConfig() RuntimeConfig { EventLogRetentionDays: 7, SkipEventLogging: false, TrustedPackages: []TrustedPackage{}, + AdvisoryMessage: "", ProxyMode: true, Verbosity: VerbosityNormal, Sandbox: SandboxConfig{ @@ -596,8 +595,8 @@ func initConfig() { loadConfig() - if err := preprocessTrustedPackages(&globalConfig.Config); err != nil { - log.Warnf("Failed to preprocess trusted packages: %v", err) + if err := preprocessPackageRefs(&globalConfig.Config); err != nil { + log.Warnf("Failed to preprocess package refs: %v", err) } } diff --git a/config/config.template.yml b/config/config.template.yml index 893c1e0..91120c0 100644 --- a/config/config.template.yml +++ b/config/config.template.yml @@ -32,6 +32,13 @@ skip_event_logging: false # This is the number of days to retain event logs. event_log_retention_days: 7 +# Optional message appended to every block output, regardless of which control +# blocked the installation (malware analysis, dependency cooldown, etc.). +# Useful for org deployments to point developers at internal policy docs or a +# security contact. Example: +# advisory_message: "Blocked by ACME security policy. Questions? #security-help" +advisory_message: "" + # Proxy configuration. # When enabled, PMG uses a proxy-based interception approach instead of the # default guard-based analysis. The proxy intercepts package manager requests in real-time diff --git a/config/config_template_test.go b/config/config_template_test.go index 46bd537..011ff93 100644 --- a/config/config_template_test.go +++ b/config/config_template_test.go @@ -34,6 +34,7 @@ func TestTemplateParsesAsYAML(t *testing.T) { assert.False(t, false, cfg.SkipEventLogging, "expected SkipEventLogging false") assert.Equal(t, 7, cfg.EventLogRetentionDays, "expected EventLogRetentionDays 7") assert.Len(t, cfg.TrustedPackages, 1) + assert.Empty(t, cfg.AdvisoryMessage) } func TestTemplateMatchesDefaults(t *testing.T) { @@ -66,6 +67,7 @@ func TestTemplateMatchesDefaults(t *testing.T) { assert.Equal(t, def.DependencyCooldown.Enabled, parsed.DependencyCooldown.Enabled, "dependency_cooldown.enabled mismatch") assert.Equal(t, def.DependencyCooldown.Days, parsed.DependencyCooldown.Days, "dependency_cooldown.days mismatch") + assert.Equal(t, def.AdvisoryMessage, parsed.AdvisoryMessage, "advisory_message mismatch") assert.Equal(t, def.Cloud.Enabled, parsed.Cloud.Enabled, "cloud.enabled mismatch") } diff --git a/config/cooldown_skip_test.go b/config/cooldown_skip_test.go index 70fc4e7..4f5ec5a 100644 --- a/config/cooldown_skip_test.go +++ b/config/cooldown_skip_test.go @@ -19,7 +19,7 @@ func TestIsTrustedPackageRef(t *testing.T) { {Purl: "pkg:npm/all-versions"}, {Purl: "pkg:npm/pinned@1.0.0"}, }} - _ = preprocessTrustedPackages(cfg) + _ = preprocessPackageRefs(cfg) setGlobalForTest(t, cfg) assert.True(t, IsTrustedPackageRef(packagev1.Ecosystem_ECOSYSTEM_NPM, "all-versions", "9.9.9")) @@ -33,7 +33,7 @@ func TestIsTrustedPackageAllVersions(t *testing.T) { {Purl: "pkg:npm/all-versions"}, {Purl: "pkg:npm/pinned@1.0.0"}, }} - _ = preprocessTrustedPackages(cfg) + _ = preprocessPackageRefs(cfg) setGlobalForTest(t, cfg) assert.True(t, IsTrustedPackageAllVersions(packagev1.Ecosystem_ECOSYSTEM_NPM, "all-versions")) @@ -130,7 +130,7 @@ func TestCooldownSkip(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := &Config{DependencyCooldown: DependencyCooldownConfig{Skip: tt.skip}} - _ = preprocessTrustedPackages(cfg) + _ = preprocessPackageRefs(cfg) got := cooldownSkip(cfg.DependencyCooldown.Skip, tt.ecosystem, tt.pkgName) assert.Equal(t, tt.wantSkipAll, got.SkipAll) @@ -156,7 +156,7 @@ func TestCooldownSkipIsSkipListOnly(t *testing.T) { TrustedPackages: []TrustedPackage{{Purl: "pkg:npm/trusted-only"}}, DependencyCooldown: DependencyCooldownConfig{Skip: []TrustedPackage{{Purl: "pkg:npm/cooldown-only"}}}, } - _ = preprocessTrustedPackages(cfg) + _ = preprocessPackageRefs(cfg) setGlobalForTest(t, cfg) assert.False(t, CooldownSkip(packagev1.Ecosystem_ECOSYSTEM_NPM, "trusted-only").SkipAll, "trusted_packages must not leak into CooldownSkip") diff --git a/config/packageref.go b/config/packageref.go new file mode 100644 index 0000000..ed24c67 --- /dev/null +++ b/config/packageref.go @@ -0,0 +1,49 @@ +package config + +import ( + packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" + "github.com/safedep/dry/api/pb" + "github.com/safedep/dry/log" +) + +// purlRef is the pre-parsed form of a PURL list entry (trusted_packages, +// dependency_cooldown.skip). Parsing happens once at config load; entries +// with an invalid PURL are marked unparsed and never match. +type purlRef struct { + parsed bool + ecosystem packagev1.Ecosystem + name string + version string +} + +func (r *purlRef) parseFrom(purl string) { + parsedPurl, err := pb.NewPurlPackageVersion(purl) + if err != nil { + log.Warnf("Failed to parse package PURL: %s: %v", purl, err) + r.parsed = false + return + } + + r.parsed = true + r.ecosystem = parsedPurl.Ecosystem() + r.name = parsedPurl.Name() + r.version = parsedPurl.Version() +} + +// matches reports whether the ref matches a package version. A version-less +// ref matches every version of the package. +func (r purlRef) matches(pv *packagev1.PackageVersion) bool { + if !r.parsed || pv == nil { + return false + } + if r.ecosystem != pv.GetPackage().GetEcosystem() { + return false + } + if r.name != pv.GetPackage().GetName() { + return false + } + if r.version != "" && r.version != pv.GetVersion() { + return false + } + return true +} diff --git a/config/trusted.go b/config/trusted.go index 995effb..f108ff3 100644 --- a/config/trusted.go +++ b/config/trusted.go @@ -2,8 +2,6 @@ package config import ( packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" - "github.com/safedep/dry/api/pb" - "github.com/safedep/dry/log" ) // IsTrustedPackage checks if a package version is trusted based on global configuration. @@ -84,42 +82,24 @@ func cooldownSkip(skip []TrustedPackage, ecosystem packagev1.Ecosystem, name str return info } -// PreprocessTrustedPackages pre-parses all PURL strings in the trusted package -// lists. Exported for use in cross-package tests that install synthetic configs -// without going through Load. -func PreprocessTrustedPackages(cfg *Config) error { - return preprocessTrustedPackages(cfg) +// PreprocessPackageRefs pre-parses all PURL strings in the trusted and +// cooldown skip package lists. Exported for use in cross-package tests that +// install synthetic configs without going through Load. +func PreprocessPackageRefs(cfg *Config) error { + return preprocessPackageRefs(cfg) } -// preprocessTrustedPackages pre-parses all PURL strings in the trusted package -// lists (both the top-level guardrail list and the cooldown-exemption list). -// This is called once during config load to avoid repeated parsing during -// trusted package checks. Invalid PURLs are logged but not fatal. -func preprocessTrustedPackages(cfg *Config) error { - preprocessTrustedPackageList(cfg.TrustedPackages) - preprocessTrustedPackageList(cfg.DependencyCooldown.Skip) - return nil -} - -// preprocessTrustedPackageList parses the PURL of each entry in place, populating -// the pre-parsed ecosystem/name/version fields. Entries with an invalid PURL are -// marked unparsed (and skipped at match time) rather than failing the load. -func preprocessTrustedPackageList(packages []TrustedPackage) { - for i := range packages { - tp := &packages[i] - - parsedPurl, err := pb.NewPurlPackageVersion(tp.Purl) - if err != nil { - log.Warnf("Failed to parse trusted package PURL: %s: %v", tp.Purl, err) - tp.parsed = false - continue - } - - tp.parsed = true - tp.ecosystem = parsedPurl.Ecosystem() - tp.name = parsedPurl.Name() - tp.version = parsedPurl.Version() +// preprocessPackageRefs parses the PURL of each list entry in place, populating +// the pre-parsed purlRef. This is called once during config load to avoid +// repeated parsing at match time. Invalid PURLs are logged but not fatal. +func preprocessPackageRefs(cfg *Config) error { + for i := range cfg.TrustedPackages { + cfg.TrustedPackages[i].parseFrom(cfg.TrustedPackages[i].Purl) } + for i := range cfg.DependencyCooldown.Skip { + cfg.DependencyCooldown.Skip[i].parseFrom(cfg.DependencyCooldown.Skip[i].Purl) + } + return nil } // isTrustedPackageVersion checks if a package version is in the trusted packages list. @@ -128,33 +108,10 @@ func preprocessTrustedPackageList(packages []TrustedPackage) { // If the trusted package PURL doesn't specify a version, all versions of that package are trusted. // Returns false if pkgVersion is nil or if trustedPackages is empty. func isTrustedPackageVersion(trustedPackages []TrustedPackage, pkgVersion *packagev1.PackageVersion) bool { - if pkgVersion == nil { - return false - } - - if len(trustedPackages) == 0 { - return false - } - for _, v := range trustedPackages { - if !v.parsed { - continue + if v.matches(pkgVersion) { + return true } - - if v.ecosystem != pkgVersion.GetPackage().GetEcosystem() { - continue - } - - if v.name != pkgVersion.GetPackage().GetName() { - continue - } - - if v.version != "" && v.version != pkgVersion.GetVersion() { - continue - } - - return true } - return false } diff --git a/config/trusted_test.go b/config/trusted_test.go index ecafbd7..c0d71fc 100644 --- a/config/trusted_test.go +++ b/config/trusted_test.go @@ -258,7 +258,7 @@ func TestIsTrustedPackageVersion(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Pre-process trusted packages to populate pre-parsed fields cfg := &Config{TrustedPackages: tt.trustedPackages} - _ = preprocessTrustedPackages(cfg) + _ = preprocessPackageRefs(cfg) got := isTrustedPackageVersion(cfg.TrustedPackages, tt.pkgVersion) assert.Equal(t, tt.want, got) diff --git a/docs/dependency-cooldown.md b/docs/dependency-cooldown.md index 23628de..2da886d 100644 --- a/docs/dependency-cooldown.md +++ b/docs/dependency-cooldown.md @@ -18,6 +18,10 @@ dependency_cooldown: days: 5 ``` +To show an org-specific message whenever PMG blocks an installation (cooldown +or otherwise), see the top-level `advisory_message` in the +[config template](../config/config.template.yml). + ## Exempting Specific Packages Some packages — typically first-party or internal — need to be installed as soon diff --git a/internal/flows/proxy_flow.go b/internal/flows/proxy_flow.go index 433f30c..d5bc1a0 100644 --- a/internal/flows/proxy_flow.go +++ b/internal/flows/proxy_flow.go @@ -291,6 +291,7 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema reportData.BlockedPackages = statsCollector.GetBlockedPackages() reportData.ConfirmedPackages = statsCollector.GetConfirmedPackages() reportData.CooldownBlockedPackages = statsCollector.GetCooldownBlocks() + reportData.AdvisoryMessage = cfg.Config.AdvisoryMessage // Set outcome based on execution result using shared inference logic reportData.Outcome = inferOutcome(cfg.InsecureInstallation, cfg.DryRun, reportData.BlockedCount, stats.UserCancelledCount, executionError) diff --git a/internal/ui/report.go b/internal/ui/report.go index 3c42963..6f46cbe 100644 --- a/internal/ui/report.go +++ b/internal/ui/report.go @@ -82,6 +82,10 @@ type ReportData struct { // Packages blocked by the dependency cooldown policy (proxy mode only) CooldownBlockedPackages []models.CooldownBlock + // AdvisoryMessage is the optional org-configured message appended to block + // output regardless of which control blocked. Set from advisory_message. + AdvisoryMessage string + // Configuration context FlowType FlowType DryRun bool @@ -135,10 +139,27 @@ func Report(data *ReportData) { } } -// reportSilent only shows output on errors or blocks -// Normal successful execution produces no output +func printMalwareBlockSection(data *ReportData) { + if len(data.BlockedPackages) == 0 { + return + } + + fmt.Println() + fmt.Printf("%s %s\n", Colors.Red("✗"), Colors.Red("Malicious package blocked")) + printMaliciousPackagesList(data.BlockedPackages) + fmt.Println() +} + +// reportSilent shows output only when the install was blocked: silent mode +// hides PMG except for errors and malicious package detection. Cooldown-only +// blocks stay hidden, matching the documented silent contract. func reportSilent(data *ReportData) { - // Silent mode: no report output + if data.Outcome != OutcomeBlocked || len(data.BlockedPackages) == 0 { + return + } + + printMalwareBlockSection(data) + printAdvisoryMessage(data.AdvisoryMessage) } // reportNormal shows minimal, assuring output @@ -176,12 +197,7 @@ func reportNormal(data *ReportData) { switch data.Outcome { case OutcomeBlocked: - if len(data.BlockedPackages) > 0 { - fmt.Println() - fmt.Printf("%s %s\n", Colors.Red("✗"), Colors.Red("Malicious package blocked")) - printMaliciousPackagesList(data.BlockedPackages) - fmt.Println() - } + printMalwareBlockSection(data) if len(data.CooldownBlockedPackages) > 0 { fmt.Println() @@ -193,6 +209,11 @@ func reportNormal(data *ReportData) { fmt.Println() } + if data.AdvisoryMessage != "" { + printAdvisoryMessage(data.AdvisoryMessage) + fmt.Println() + } + onlyCooldown := len(data.BlockedPackages) == 0 && len(data.CooldownBlockedPackages) > 0 if onlyCooldown { icon = Colors.Yellow("⊘") @@ -307,6 +328,11 @@ func reportVerbose(data *ReportData) { } } + if data.Outcome == OutcomeBlocked && data.AdvisoryMessage != "" { + fmt.Println() + printAdvisoryMessage(data.AdvisoryMessage) + } + fmt.Println() } diff --git a/internal/ui/report_test.go b/internal/ui/report_test.go new file mode 100644 index 0000000..12be967 --- /dev/null +++ b/internal/ui/report_test.go @@ -0,0 +1,138 @@ +package ui + +import ( + "io" + "os" + "strings" + "testing" + + packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" + "github.com/safedep/pmg/analyzer" + "github.com/safedep/pmg/internal/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + old := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stdout = w + defer func() { os.Stdout = old }() + + fn() + + require.NoError(t, w.Close()) + out, err := io.ReadAll(r) + require.NoError(t, err) + return string(out) +} + +func malwareBlockedData() *ReportData { + data := NewReportData() + data.TotalAnalyzed = 1 + data.BlockedCount = 1 + data.Outcome = OutcomeBlocked + data.BlockedPackages = []*analyzer.PackageVersionAnalysisResult{ + { + PackageVersion: &packagev1.PackageVersion{ + Package: &packagev1.Package{Name: "evil", Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM}, + Version: "1.0.0", + }, + Summary: "verified malware", + }, + } + return data +} + +func TestReportNormalMalwareAdvisoryMessage(t *testing.T) { + withVerbosity(t, VerbosityLevelNormal) + + data := malwareBlockedData() + data.AdvisoryMessage = "Contact #security-help" + + out := captureStdout(t, func() { Report(data) }) + assert.Contains(t, out, "Malicious package blocked") + assert.Contains(t, out, "ℹ Contact #security-help") + assert.NotContains(t, out, "\n\n\n", "no double blank lines in block output") +} + +func TestReportNormalNoAdvisoryMessageWhenUnset(t *testing.T) { + withVerbosity(t, VerbosityLevelNormal) + + out := captureStdout(t, func() { Report(malwareBlockedData()) }) + assert.Contains(t, out, "Malicious package blocked") + assert.NotContains(t, out, "Contact #security-help") +} + +func TestReportNormalCooldownAdvisoryMessage(t *testing.T) { + withVerbosity(t, VerbosityLevelNormal) + + data := NewReportData() + data.TotalAnalyzed = 1 + data.BlockedCount = 1 + data.Outcome = OutcomeBlocked + data.CooldownBlockedPackages = []models.CooldownBlock{{Name: "fresh", Version: "2.0.0", DaysAgo: 1, DaysLeft: 4, CooldownDays: 5}} + data.AdvisoryMessage = "Request an exemption at go/pmg-exceptions" + + out := captureStdout(t, func() { Report(data) }) + assert.Contains(t, out, "Dependency cooldown") + assert.Contains(t, out, "Request an exemption at go/pmg-exceptions") +} + +func TestReportSilentRendersBlocks(t *testing.T) { + withVerbosity(t, VerbosityLevelSilent) + + data := malwareBlockedData() + data.AdvisoryMessage = "Contact #security-help" + + out := captureStdout(t, func() { Report(data) }) + assert.Contains(t, out, "Malicious package blocked") + assert.Contains(t, out, "Contact #security-help") +} + +func TestReportSilentQuietOnSuccess(t *testing.T) { + withVerbosity(t, VerbosityLevelSilent) + + data := NewReportData() + data.TotalAnalyzed = 3 + + out := captureStdout(t, func() { Report(data) }) + assert.Empty(t, out) +} + +func TestReportSilentCooldownOnlyStaysQuiet(t *testing.T) { + withVerbosity(t, VerbosityLevelSilent) + + data := NewReportData() + data.TotalAnalyzed = 1 + data.BlockedCount = 1 + data.Outcome = OutcomeBlocked + data.CooldownBlockedPackages = []models.CooldownBlock{{Name: "fresh", Version: "2.0.0"}} + + out := captureStdout(t, func() { Report(data) }) + assert.Empty(t, out) +} + +func TestReportVerboseAdvisoryMessage(t *testing.T) { + withVerbosity(t, VerbosityLevelVerbose) + + data := malwareBlockedData() + data.AdvisoryMessage = "Contact #security-help" + + out := captureStdout(t, func() { Report(data) }) + assert.Contains(t, out, "Installation blocked") + assert.Contains(t, out, "ℹ Contact #security-help") +} + +func TestTermWidthFormatTextIndent(t *testing.T) { + text := strings.Repeat("word ", 40) + out := termWidthFormatTextIndent(text, 20, " ") + lines := strings.Split(out, "\n") + require.Greater(t, len(lines), 1) + for _, line := range lines[1:] { + assert.True(t, strings.HasPrefix(line, " "), "wrapped line must be indented: %q", line) + } +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 70c2496..e8f1640 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -196,7 +196,7 @@ func printMaliciousPackagesList(malwarePackages []*analyzer.PackageVersionAnalys mp.PackageVersion.GetVersion()))) if verbosityLevel == VerbosityLevelVerbose { - fmt.Printf(" %s\n", Colors.Dim(termWidthFormatText(mp.Summary, 76))) + fmt.Printf(" %s\n", Colors.Dim(termWidthFormatTextIndent(mp.Summary, 76, " "))) } if mp.ReferenceURL != "" { @@ -228,6 +228,16 @@ func printCooldownPackagesList(packages []models.CooldownBlock) { } } +// printAdvisoryMessage renders the org-configured advisory_message as an info +// note attached to the block output. Callers are responsible for surrounding +// blank lines. No-op when the message is empty. +func printAdvisoryMessage(message string) { + if message == "" { + return + } + fmt.Printf(" %s %s\n", Colors.Cyan("ℹ"), Colors.Cyan(termWidthFormatTextIndent(message, 76, " "))) +} + func pluralizeDays(n int) string { if n == 1 { return "1 day" @@ -242,6 +252,12 @@ func pluralizePackages(n int) string { return fmt.Sprintf("%d packages", n) } +// termWidthFormatTextIndent wraps text at maxWidth and indents continuation +// lines so wrapped output stays aligned with the first line. +func termWidthFormatTextIndent(text string, maxWidth int, indent string) string { + return strings.ReplaceAll(termWidthFormatText(text, maxWidth), "\n", "\n"+indent) +} + // Format the string to be maximum maxWidth. Use newlines to wrap the text. func termWidthFormatText(text string, maxWidth int) string { // Replace all newlines with spaces so that we can split the text into words diff --git a/proxy/interceptors/base_registry.go b/proxy/interceptors/base_registry.go index e1cd584..70e72dd 100644 --- a/proxy/interceptors/base_registry.go +++ b/proxy/interceptors/base_registry.go @@ -101,6 +101,15 @@ func (b *baseRegistryInterceptor) fastAllow( return nil, false } +// appendAdvisoryMessage appends the org-configured advisory_message, when set, +// to a block message body. +func appendAdvisoryMessage(message, advisory string) string { + if advisory == "" { + return message + } + return message + "\n\n" + advisory +} + // analyzePackage analyzes a package using the configured analyzer with caching // This method is ecosystem-agnostic and can be used by any registry interceptor func (b *baseRegistryInterceptor) analyzePackage( @@ -194,11 +203,11 @@ func (b *baseRegistryInterceptor) handleAnalysisResult( b.statsCollector.RecordBlocked(result) } - message := fmt.Sprintf("Malicious package blocked: %s/%s@%s\n\nReason: %s\n\nReference: %s", + message := appendAdvisoryMessage(fmt.Sprintf("Malicious package blocked: %s/%s@%s\n\nReason: %s\n\nReference: %s", ecosystem.String(), packageName, packageVersion, result.Summary, - result.ReferenceURL) + result.ReferenceURL), config.Get().Config.AdvisoryMessage) return &proxy.InterceptorResponse{ Action: proxy.ActionBlock, @@ -233,11 +242,11 @@ func (b *baseRegistryInterceptor) handleAnalysisResult( b.statsCollector.RecordUserCancelled(result) } - message := fmt.Sprintf("Installation blocked by user: %s/%s@%s\n\nReason: %s\n\nReference: %s", + message := appendAdvisoryMessage(fmt.Sprintf("Installation blocked by user: %s/%s@%s\n\nReason: %s\n\nReference: %s", ecosystem.String(), packageName, packageVersion, result.Summary, - result.ReferenceURL) + result.ReferenceURL), config.Get().Config.AdvisoryMessage) return &proxy.InterceptorResponse{ Action: proxy.ActionBlock, diff --git a/proxy/interceptors/base_registry_test.go b/proxy/interceptors/base_registry_test.go index 0fad3ed..ae2ce8c 100644 --- a/proxy/interceptors/base_registry_test.go +++ b/proxy/interceptors/base_registry_test.go @@ -18,10 +18,10 @@ func setTrustedPackagesForTest(t *testing.T, pkgs []pmgconfig.TrustedPackage) { t.Helper() orig := pmgconfig.Get().Config.TrustedPackages pmgconfig.Get().Config.TrustedPackages = pkgs - require.NoError(t, pmgconfig.PreprocessTrustedPackages(&pmgconfig.Get().Config), "setTrustedPackagesForTest: preprocess") + require.NoError(t, pmgconfig.PreprocessPackageRefs(&pmgconfig.Get().Config), "setTrustedPackagesForTest: preprocess") t.Cleanup(func() { pmgconfig.Get().Config.TrustedPackages = orig - assert.NoError(t, pmgconfig.PreprocessTrustedPackages(&pmgconfig.Get().Config)) + assert.NoError(t, pmgconfig.PreprocessPackageRefs(&pmgconfig.Get().Config)) }) } @@ -204,3 +204,31 @@ func TestBaseRegistryInterceptor_HandleAnalysisResult(t *testing.T) { }) } } + +func TestAppendAdvisoryMessage(t *testing.T) { + assert.Equal(t, "base", appendAdvisoryMessage("base", "")) + assert.Equal(t, "base\n\ncustom", appendAdvisoryMessage("base", "custom")) +} + +func TestHandleAnalysisResultBlockCarriesAdvisoryMessage(t *testing.T) { + origMsg := pmgconfig.Get().Config.AdvisoryMessage + pmgconfig.Get().Config.AdvisoryMessage = "Contact #security-help" + t.Cleanup(func() { pmgconfig.Get().Config.AdvisoryMessage = origMsg }) + + b := &baseRegistryInterceptor{} + ctx := makeTestRequestContext("https://registry.npmjs.org/evil/-/evil-1.0.0.tgz") + + result := &analyzer.PackageVersionAnalysisResult{ + PackageVersion: &packagev1.PackageVersion{ + Package: &packagev1.Package{Name: "evil", Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM}, + Version: "1.0.0", + }, + Action: analyzer.ActionBlock, + Summary: "verified malware", + } + + resp, err := b.handleAnalysisResult(ctx, packagev1.Ecosystem_ECOSYSTEM_NPM, "evil", "1.0.0", result) + require.NoError(t, err) + assert.Equal(t, proxy.ActionBlock, resp.Action) + assert.Contains(t, resp.BlockMessage, "Contact #security-help") +} diff --git a/proxy/interceptors/go_cooldown.go b/proxy/interceptors/go_cooldown.go index b0ea6c9..980bc42 100644 --- a/proxy/interceptors/go_cooldown.go +++ b/proxy/interceptors/go_cooldown.go @@ -126,8 +126,10 @@ func (h *goCooldownHandler) CheckZipDownload(ctx *proxy.RequestContext, baseURL, pv.SetVersion(version) audit.LogDependencyCooldown(pv, publishTime, cooldownDays, daysAgo, daysLeft) - message := fmt.Sprintf("Package blocked by dependency cooldown: GO/%s@%s\n\nPublished %d day(s) ago; cooldown window is %d day(s) (%d remaining).", - module, version, daysAgo, cooldownDays, daysLeft) + message := appendAdvisoryMessage( + fmt.Sprintf("Package blocked by dependency cooldown: GO/%s@%s\n\nPublished %d day(s) ago; cooldown window is %d day(s) (%d remaining).", + module, version, daysAgo, cooldownDays, daysLeft), + pmgconfig.Get().Config.AdvisoryMessage) return &proxy.InterceptorResponse{ Action: proxy.ActionBlock, diff --git a/test/proxye2e/proxye2e_test.go b/test/proxye2e/proxye2e_test.go index eec61be..916d1f7 100644 --- a/test/proxye2e/proxye2e_test.go +++ b/test/proxye2e/proxye2e_test.go @@ -567,3 +567,49 @@ func TestProxyFlow_Go(t *testing.T) { }, }) } + +func blockedBody(res ExecResult) string { + for _, r := range res.Requests { + if r.Blocked { + return r.Body + } + } + return "" +} + +func TestProxyFlow_AdvisoryMessage(t *testing.T) { + RunCases(t, []TestCase{ + { + Name: "malware block body carries advisory message", + Config: func(rc *config.RuntimeConfig) { + rc.Config.AdvisoryMessage = "Report false positives in #security-help" + }, + Setup: func(h *Harness) { + h.Registry.AddNpm(NpmPackage{Name: "evil", DistTagLatest: "1.0.0", + Versions: []NpmVersion{{Version: "1.0.0", PublishedAt: old()}}}) + h.Analyzer.SetNpm("evil", "1.0.0", VerifiedMalware()) + }, + Exec: func(h *Harness) ExecResult { return h.Npm().Install("evil", "1.0.0") }, + Assert: func(t *testing.T, h *Harness, res ExecResult) { + assert.True(t, res.Blocked()) + assert.Contains(t, blockedBody(res), "Report false positives in #security-help") + }, + }, + { + Name: "go cooldown block body carries advisory message", + Config: func(rc *config.RuntimeConfig) { + rc.Config.DependencyCooldown = config.DependencyCooldownConfig{Enabled: true, Days: 7} + rc.Config.AdvisoryMessage = "Request an exemption at go/pmg-exceptions" + }, + Setup: func(h *Harness) { + h.Registry.AddGoModule(GoModule{Path: "example.com/fresh", + Versions: []GoVersion{{Version: "v1.0.0", PublishedAt: recent()}}}) + }, + Exec: func(h *Harness) ExecResult { return h.Go().Install("example.com/fresh", "v1.0.0") }, + Assert: func(t *testing.T, h *Harness, res ExecResult) { + assert.True(t, res.Blocked()) + assert.Contains(t, blockedBody(res), "Request an exemption at go/pmg-exceptions") + }, + }, + }) +} diff --git a/test/proxye2e/runner.go b/test/proxye2e/runner.go index 38ea40c..34ddebd 100644 --- a/test/proxye2e/runner.go +++ b/test/proxye2e/runner.go @@ -59,12 +59,13 @@ func applyConfig(t *testing.T, override func(rc *config.RuntimeConfig)) { rc.Config.Paranoid = false rc.Config.TrustedPackages = nil rc.Config.DependencyCooldown = config.DependencyCooldownConfig{} + rc.Config.AdvisoryMessage = "" if override != nil { override(rc) } - if err := config.PreprocessTrustedPackages(&rc.Config); err != nil { + if err := config.PreprocessPackageRefs(&rc.Config); err != nil { t.Fatalf("failed to preprocess trusted packages: %v", err) } }