mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
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
This commit is contained in:
+8
-9
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+17
-60
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+35
-9
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+17
-1
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user