mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
test: Add proxy e2e test (#348)
* test: Add proxy e2e test * fix: Code review fixes * test: Add dependency cooldown skip list test case
This commit is contained in:
@@ -22,6 +22,16 @@ go test ./config/ -v -count=1 # Run specific package tests
|
||||
- `analyzer/` — Package security analysis
|
||||
- `internal/` — Internal utilities (analytics, eventlog, flows, ui)
|
||||
|
||||
## Proxy E2E Tests
|
||||
|
||||
- `test/proxye2e/` is a hermetic (no network) table-driven framework for the proxy flow. It
|
||||
runs the real proxy, interceptors, cooldown handlers and analyzer verdict-mapping against an
|
||||
in-process mock registry and a stub malysis gRPC client.
|
||||
- Any security-sensitive change to the proxy flow (interceptors, cooldown, malware
|
||||
allow/confirm/block, trusted/insecure bypass, new controls) MUST add or extend an E2E case
|
||||
in `test/proxye2e/`. Add a `TestCase` with `Config`/`Setup`/`Exec`/`Assert`; do not build new
|
||||
scaffolding.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Keep things short and simple
|
||||
|
||||
@@ -49,9 +49,24 @@ func NewMalysisQueryAnalyzer(config MalysisQueryAnalyzerConfig) (*malysisQueryAn
|
||||
return nil, fmt.Errorf("failed to create gRPC client: %w", err)
|
||||
}
|
||||
|
||||
return NewMalysisQueryAnalyzerWithClient(malysisv1grpc.NewMalwareAnalysisServiceClient(client), config, false)
|
||||
}
|
||||
|
||||
// NewMalysisQueryAnalyzerWithClient builds an analyzer over a caller-supplied
|
||||
// gRPC client. It is the shared constructor behind the community and
|
||||
// authenticated variants, and the injection seam used by tests to drive the
|
||||
// real verdict-mapping path over a stub client. honorExclusions mirrors the
|
||||
// authenticated analyzer's tenant-exclusion behavior.
|
||||
func NewMalysisQueryAnalyzerWithClient(client malysisv1grpc.MalwareAnalysisServiceClient,
|
||||
config MalysisQueryAnalyzerConfig, honorExclusions bool) (*malysisQueryAnalyzer, error) {
|
||||
if client == nil {
|
||||
return nil, fmt.Errorf("malysis client must not be nil")
|
||||
}
|
||||
|
||||
return &malysisQueryAnalyzer{
|
||||
client: malysisv1grpc.NewMalwareAnalysisServiceClient(client),
|
||||
Config: config,
|
||||
client: client,
|
||||
Config: config,
|
||||
honorExclusions: honorExclusions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -67,11 +82,8 @@ func NewMalysisAuthenticatedQueryAnalyzer(config MalysisQueryAnalyzerConfig,
|
||||
return nil, fmt.Errorf("failed to create authenticated gRPC client: %w", err)
|
||||
}
|
||||
|
||||
return &malysisQueryAnalyzer{
|
||||
client: malysisv1grpc.NewMalwareAnalysisServiceClient(cloudClient.Connection()),
|
||||
Config: config,
|
||||
honorExclusions: true,
|
||||
}, nil
|
||||
return NewMalysisQueryAnalyzerWithClient(
|
||||
malysisv1grpc.NewMalwareAnalysisServiceClient(cloudClient.Connection()), config, true)
|
||||
}
|
||||
|
||||
func (a *malysisQueryAnalyzer) Name() string {
|
||||
|
||||
+33
-6
@@ -82,6 +82,17 @@ type ProxyConfig struct {
|
||||
// UpstreamRetries bounds retries of idempotent upstream requests on
|
||||
// transient round-trip failures. Zero disables retries.
|
||||
UpstreamRetries int
|
||||
|
||||
// UpstreamDialContext, when set, replaces the default dialer for all upstream
|
||||
// connections — both MITM'd round-trips and the CONNECT tunnels used for
|
||||
// non-MITM hosts. Tests use it to redirect every hostname to a mock server so
|
||||
// no path reaches the network; production leaves it nil.
|
||||
UpstreamDialContext func(ctx context.Context, network, addr string) (net.Conn, error)
|
||||
|
||||
// UpstreamTLSClientConfig, when set, overrides the TLS config used for
|
||||
// upstream connections (e.g. to trust a mock registry's certificate). nil
|
||||
// keeps the production default.
|
||||
UpstreamTLSClientConfig *tls.Config
|
||||
}
|
||||
|
||||
// DefaultProxyConfig returns a configuration with sensible defaults
|
||||
@@ -144,8 +155,14 @@ func NewProxyServer(config *ProxyConfig) (ProxyServer, error) {
|
||||
// the output is actually wanted.
|
||||
proxy.Verbose = strings.EqualFold(os.Getenv("APP_LOG_LEVEL"), "debug")
|
||||
|
||||
// Configure connection timeout for upstream connections during CONNECT requests
|
||||
// Configure connection timeout for upstream connections during CONNECT requests.
|
||||
// A custom UpstreamDialContext also governs CONNECT tunnels so non-MITM hosts
|
||||
// are dialed through the same override (tests rely on this for hermeticity).
|
||||
proxy.ConnectDial = func(network, addr string) (net.Conn, error) {
|
||||
if config.UpstreamDialContext != nil {
|
||||
return config.UpstreamDialContext(context.Background(), network, addr)
|
||||
}
|
||||
|
||||
dialer := &net.Dialer{
|
||||
Timeout: config.ConnectTimeout,
|
||||
}
|
||||
@@ -192,6 +209,19 @@ func newUpstreamTransport(config *ProxyConfig) *http.Transport {
|
||||
Timeout: config.ConnectTimeout,
|
||||
}
|
||||
|
||||
dialContext := dialer.DialContext
|
||||
if config.UpstreamDialContext != nil {
|
||||
dialContext = config.UpstreamDialContext
|
||||
}
|
||||
|
||||
tlsClientConfig := &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
InsecureSkipVerify: false,
|
||||
}
|
||||
if config.UpstreamTLSClientConfig != nil {
|
||||
tlsClientConfig = config.UpstreamTLSClientConfig
|
||||
}
|
||||
|
||||
// Proxy honours the environment (HTTP_PROXY, HTTPS_PROXY, NO_PROXY) so
|
||||
// that PMG works in enterprise environments that require a corporate
|
||||
// upstream proxy to reach the internet. Loopback addresses are always
|
||||
@@ -213,7 +243,7 @@ func newUpstreamTransport(config *ProxyConfig) *http.Transport {
|
||||
// connection reuse.
|
||||
return &http.Transport{
|
||||
Proxy: proxyWithLoopbackBypass,
|
||||
DialContext: dialer.DialContext,
|
||||
DialContext: dialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxConnsPerHost: 100,
|
||||
MaxIdleConns: 200,
|
||||
@@ -221,10 +251,7 @@ func newUpstreamTransport(config *ProxyConfig) *http.Transport {
|
||||
IdleConnTimeout: 120 * time.Second,
|
||||
TLSHandshakeTimeout: config.ConnectTimeout,
|
||||
ResponseHeaderTimeout: config.RequestTimeout,
|
||||
TLSClientConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
InsecureSkipVerify: false,
|
||||
},
|
||||
TLSClientConfig: tlsClientConfig,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package proxye2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"buf.build/gen/go/safedep/api/grpc/go/safedep/services/malysis/v1/malysisv1grpc"
|
||||
malysisv1pb "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/malysis/v1"
|
||||
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
|
||||
malysisv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/services/malysis/v1"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// Verdict is a programmable malysis response for a package version. It is the
|
||||
// raw upstream signal: the real analyzer's verdict-mapping (suspicious→confirm,
|
||||
// paranoid upgrade, verified→block, exclusion→allow) runs on top of it.
|
||||
type Verdict struct {
|
||||
resp *malysisv1.QueryPackageAnalysisResponse
|
||||
err error
|
||||
}
|
||||
|
||||
// Clean reports no malware (allow).
|
||||
func Clean() Verdict {
|
||||
return Verdict{resp: &malysisv1.QueryPackageAnalysisResponse{
|
||||
Report: &malysisv1pb.Report{Inference: &malysisv1pb.Report_Inference{IsMalware: false, Summary: "no indicators"}},
|
||||
VerificationRecord: &malysisv1pb.VerificationRecord{IsMalware: false},
|
||||
}}
|
||||
}
|
||||
|
||||
// Suspicious reports inference-only malware (unverified). Maps to confirm, or to
|
||||
// block under paranoid mode.
|
||||
func Suspicious() Verdict {
|
||||
return Verdict{resp: &malysisv1.QueryPackageAnalysisResponse{
|
||||
Report: &malysisv1pb.Report{Inference: &malysisv1pb.Report_Inference{IsMalware: true, Summary: "suspicious patterns detected"}},
|
||||
VerificationRecord: &malysisv1pb.VerificationRecord{IsMalware: false},
|
||||
}}
|
||||
}
|
||||
|
||||
// VerifiedMalware reports verified malware (always block).
|
||||
func VerifiedMalware() Verdict {
|
||||
return Verdict{resp: &malysisv1.QueryPackageAnalysisResponse{
|
||||
Report: &malysisv1pb.Report{Inference: &malysisv1pb.Report_Inference{IsMalware: true, Summary: "verified malware"}},
|
||||
VerificationRecord: &malysisv1pb.VerificationRecord{IsMalware: true},
|
||||
}}
|
||||
}
|
||||
|
||||
// Excluded reports verified malware carrying a tenant exclusion. With an
|
||||
// exclusion-honoring analyzer it downgrades to allow.
|
||||
func Excluded(reason string) Verdict {
|
||||
v := VerifiedMalware()
|
||||
v.resp.MaliciousPackageExclusion = &malysisv1.QueryPackageAnalysisResponse_MaliciousPackageExclusion{
|
||||
ExclusionId: "e2e-exclusion",
|
||||
Reason: reason,
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// NotFound reports the package is absent from the analysis DB (treated as allow,
|
||||
// not a failure).
|
||||
func NotFound() Verdict {
|
||||
return Verdict{err: status.Error(codes.NotFound, "package not found")}
|
||||
}
|
||||
|
||||
// ServerError reports an upstream failure, exercising the fail-open path.
|
||||
func ServerError() Verdict {
|
||||
return Verdict{err: status.Error(codes.Unavailable, "analysis service unavailable")}
|
||||
}
|
||||
|
||||
type AnalyzedPackage struct {
|
||||
Ecosystem packagev1.Ecosystem
|
||||
Name string
|
||||
Version string
|
||||
}
|
||||
|
||||
// AnalyzerRecorder holds programmable verdicts and records every query the real
|
||||
// analyzer issues to the stub gRPC client.
|
||||
type AnalyzerRecorder struct {
|
||||
mu sync.Mutex
|
||||
verdicts map[string]Verdict
|
||||
calls []AnalyzedPackage
|
||||
}
|
||||
|
||||
func newAnalyzerRecorder() *AnalyzerRecorder {
|
||||
return &AnalyzerRecorder{verdicts: map[string]Verdict{}}
|
||||
}
|
||||
|
||||
func verdictKey(eco packagev1.Ecosystem, name, version string) string {
|
||||
if eco == packagev1.Ecosystem_ECOSYSTEM_PYPI {
|
||||
name = normalizePypiName(name)
|
||||
}
|
||||
return fmt.Sprintf("%s|%s|%s", eco.String(), name, version)
|
||||
}
|
||||
|
||||
func (r *AnalyzerRecorder) SetNpm(name, version string, v Verdict) {
|
||||
r.set(packagev1.Ecosystem_ECOSYSTEM_NPM, name, version, v)
|
||||
}
|
||||
|
||||
func (r *AnalyzerRecorder) SetPypi(name, version string, v Verdict) {
|
||||
r.set(packagev1.Ecosystem_ECOSYSTEM_PYPI, name, version, v)
|
||||
}
|
||||
|
||||
func (r *AnalyzerRecorder) set(eco packagev1.Ecosystem, name, version string, v Verdict) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.verdicts[verdictKey(eco, name, version)] = v
|
||||
}
|
||||
|
||||
// Calls returns every package the analyzer was queried for, in order.
|
||||
func (r *AnalyzerRecorder) Calls() []AnalyzedPackage {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]AnalyzedPackage, len(r.calls))
|
||||
copy(out, r.calls)
|
||||
return out
|
||||
}
|
||||
|
||||
// AnalyzedCount reports how many times a specific package version was queried.
|
||||
func (r *AnalyzerRecorder) AnalyzedCount(name, version string) int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
n := 0
|
||||
for _, c := range r.calls {
|
||||
if c.Name == name && c.Version == version {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (r *AnalyzerRecorder) handle(req *malysisv1.QueryPackageAnalysisRequest) (*malysisv1.QueryPackageAnalysisResponse, error) {
|
||||
pv := req.GetTarget().GetPackageVersion()
|
||||
eco := pv.GetPackage().GetEcosystem()
|
||||
name := pv.GetPackage().GetName()
|
||||
version := pv.GetVersion()
|
||||
|
||||
r.mu.Lock()
|
||||
r.calls = append(r.calls, AnalyzedPackage{Ecosystem: eco, Name: name, Version: version})
|
||||
v, ok := r.verdicts[verdictKey(eco, name, version)]
|
||||
r.mu.Unlock()
|
||||
|
||||
if !ok {
|
||||
v = Clean()
|
||||
}
|
||||
if v.err != nil {
|
||||
return nil, v.err
|
||||
}
|
||||
|
||||
resp := v.resp
|
||||
if resp.GetAnalysisId() == "" {
|
||||
resp.AnalysisId = fmt.Sprintf("e2e-%s-%s", name, version)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// stubAnalyzerClient implements the malysis gRPC client by delegating to the
|
||||
// recorder. The embedded interface satisfies the full method set; only
|
||||
// QueryPackageAnalysis is exercised by the analyzer.
|
||||
type stubAnalyzerClient struct {
|
||||
malysisv1grpc.MalwareAnalysisServiceClient
|
||||
rec *AnalyzerRecorder
|
||||
}
|
||||
|
||||
func (s *stubAnalyzerClient) QueryPackageAnalysis(_ context.Context,
|
||||
req *malysisv1.QueryPackageAnalysisRequest, _ ...grpc.CallOption) (*malysisv1.QueryPackageAnalysisResponse, error) {
|
||||
return s.rec.handle(req)
|
||||
}
|
||||
|
||||
// normalizePypiName mirrors the interceptor's PyPI name canonicalization so
|
||||
// programmed verdicts key match the name the analyzer is queried with.
|
||||
func normalizePypiName(name string) string {
|
||||
name = strings.ToLower(name)
|
||||
name = strings.ReplaceAll(name, "_", "-")
|
||||
name = strings.ReplaceAll(name, ".", "-")
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package proxye2e
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/safedep/pmg/analyzer"
|
||||
"github.com/safedep/pmg/guard"
|
||||
)
|
||||
|
||||
// ConfirmController drives the suspicious-package confirmation prompt. The
|
||||
// default policy denies, matching PMG's safe default for an unhandled prompt.
|
||||
type ConfirmController struct {
|
||||
mu sync.Mutex
|
||||
policy func([]*analyzer.PackageVersionAnalysisResult) (bool, error)
|
||||
prompts [][]string
|
||||
}
|
||||
|
||||
func newConfirmController() *ConfirmController {
|
||||
return &ConfirmController{policy: func([]*analyzer.PackageVersionAnalysisResult) (bool, error) { return false, nil }}
|
||||
}
|
||||
|
||||
func (c *ConfirmController) AutoApprove() {
|
||||
c.setPolicy(func([]*analyzer.PackageVersionAnalysisResult) (bool, error) { return true, nil })
|
||||
}
|
||||
|
||||
func (c *ConfirmController) AutoDeny() {
|
||||
c.setPolicy(func([]*analyzer.PackageVersionAnalysisResult) (bool, error) { return false, nil })
|
||||
}
|
||||
|
||||
func (c *ConfirmController) Func(fn func([]*analyzer.PackageVersionAnalysisResult) (bool, error)) {
|
||||
c.setPolicy(fn)
|
||||
}
|
||||
|
||||
func (c *ConfirmController) setPolicy(fn func([]*analyzer.PackageVersionAnalysisResult) (bool, error)) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.policy = fn
|
||||
}
|
||||
|
||||
// Prompts returns the package names presented for confirmation, one entry per prompt.
|
||||
func (c *ConfirmController) Prompts() [][]string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
out := make([][]string, len(c.prompts))
|
||||
copy(out, c.prompts)
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *ConfirmController) interaction() *guard.PackageManagerGuardInteraction {
|
||||
return &guard.PackageManagerGuardInteraction{
|
||||
GetConfirmationOnMalware: func(pkgs []*analyzer.PackageVersionAnalysisResult) (bool, error) {
|
||||
names := make([]string, 0, len(pkgs))
|
||||
for _, p := range pkgs {
|
||||
names = append(names, p.PackageVersion.GetPackage().GetName())
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.prompts = append(c.prompts, names)
|
||||
policy := c.policy
|
||||
c.mu.Unlock()
|
||||
|
||||
return policy(pkgs)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package proxye2e
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RequestOutcome struct {
|
||||
URL string
|
||||
StatusCode int
|
||||
Blocked bool
|
||||
Body string
|
||||
Err error
|
||||
}
|
||||
|
||||
// ExecResult is the aggregate of requests an install driver issued.
|
||||
type ExecResult struct {
|
||||
Requests []RequestOutcome
|
||||
}
|
||||
|
||||
func (e *ExecResult) add(o RequestOutcome) { e.Requests = append(e.Requests, o) }
|
||||
|
||||
// Blocked reports whether any request was blocked by the proxy.
|
||||
func (e ExecResult) Blocked() bool {
|
||||
for _, r := range e.Requests {
|
||||
if r.Blocked {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type NpmDriver struct{ h *Harness }
|
||||
|
||||
type NpmMetadata struct {
|
||||
Outcome RequestOutcome
|
||||
DistTags map[string]string `json:"dist-tags"`
|
||||
Versions map[string]json.RawMessage `json:"versions"`
|
||||
Time map[string]string `json:"time"`
|
||||
}
|
||||
|
||||
func (m NpmMetadata) HasVersion(v string) bool {
|
||||
_, ok := m.Versions[v]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (d NpmDriver) FetchMetadata(name string) NpmMetadata {
|
||||
out := d.h.get(fmt.Sprintf("https://registry.npmjs.org/%s", name), nil)
|
||||
|
||||
meta := NpmMetadata{Outcome: out}
|
||||
if out.Err == nil && out.StatusCode == 200 {
|
||||
if err := json.Unmarshal([]byte(out.Body), &meta); err != nil {
|
||||
meta.Outcome.Err = fmt.Errorf("failed to decode npm metadata for %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
func (d NpmDriver) Download(name, version string) RequestOutcome {
|
||||
return d.h.get(fmt.Sprintf("https://registry.npmjs.org/%s/-/%s-%s.tgz", name, name, version), nil)
|
||||
}
|
||||
|
||||
// Install replays npm's resolve-then-download sequence: fetch the packument,
|
||||
// pick the requested version (or dist-tags.latest), and download it only if it
|
||||
// survived in the metadata the proxy returned.
|
||||
func (d NpmDriver) Install(name, version string) ExecResult {
|
||||
res := ExecResult{}
|
||||
|
||||
meta := d.FetchMetadata(name)
|
||||
res.add(meta.Outcome)
|
||||
|
||||
target := version
|
||||
if target == "" {
|
||||
target = meta.DistTags["latest"]
|
||||
}
|
||||
|
||||
if target != "" && meta.HasVersion(target) {
|
||||
res.add(d.Download(name, target))
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
type PypiDriver struct{ h *Harness }
|
||||
|
||||
type PypiSimpleFile struct {
|
||||
Filename string `json:"filename"`
|
||||
URL string `json:"url"`
|
||||
UploadTime string `json:"upload-time"`
|
||||
}
|
||||
|
||||
type PypiSimple struct {
|
||||
Outcome RequestOutcome
|
||||
Files []PypiSimpleFile `json:"files"`
|
||||
}
|
||||
|
||||
func (s PypiSimple) fileForVersion(name, version string) (PypiSimpleFile, bool) {
|
||||
prefix := fmt.Sprintf("%s-%s.", normalizePypiName(name), version)
|
||||
for _, f := range s.Files {
|
||||
if strings.HasPrefix(f.Filename, prefix) {
|
||||
return f, true
|
||||
}
|
||||
}
|
||||
return PypiSimpleFile{}, false
|
||||
}
|
||||
|
||||
func (s PypiSimple) HasVersion(name, version string) bool {
|
||||
_, ok := s.fileForVersion(name, version)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (d PypiDriver) FetchSimple(name string) PypiSimple {
|
||||
out := d.h.get(
|
||||
fmt.Sprintf("https://pypi.org/simple/%s/", normalizePypiName(name)),
|
||||
map[string]string{"Accept": pypiSimpleContentType},
|
||||
)
|
||||
|
||||
simple := PypiSimple{Outcome: out}
|
||||
if out.Err == nil && out.StatusCode == 200 {
|
||||
if err := json.Unmarshal([]byte(out.Body), &simple); err != nil {
|
||||
simple.Outcome.Err = fmt.Errorf("failed to decode PyPI simple index for %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
return simple
|
||||
}
|
||||
|
||||
func (d PypiDriver) Download(fileURL string) RequestOutcome {
|
||||
return d.h.get(fileURL, nil)
|
||||
}
|
||||
|
||||
// Install replays pip's resolve-then-download sequence over the PEP 691 Simple
|
||||
// API: fetch the index, then download the requested version's file only if it
|
||||
// survived cooldown stripping.
|
||||
func (d PypiDriver) Install(name, version string) ExecResult {
|
||||
res := ExecResult{}
|
||||
|
||||
simple := d.FetchSimple(name)
|
||||
res.add(simple.Outcome)
|
||||
|
||||
if f, ok := simple.fileForVersion(name, version); ok {
|
||||
res.add(d.Download(f.URL))
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package proxye2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
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/safedep/pmg/proxy"
|
||||
"github.com/safedep/pmg/proxy/certmanager"
|
||||
"github.com/safedep/pmg/proxy/interceptors"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Harness wires the real proxy, interceptors and analyzer against an in-process
|
||||
// mock registry and a stub malysis client. It is the single entry point a test
|
||||
// case uses to register fixtures, drive traffic and assert outcomes.
|
||||
type Harness struct {
|
||||
t *testing.T
|
||||
|
||||
Registry *Registry
|
||||
Analyzer *AnalyzerRecorder
|
||||
Confirm *ConfirmController
|
||||
|
||||
stats *interceptors.AnalysisStatsCollector
|
||||
proxy proxy.ProxyServer
|
||||
client *http.Client
|
||||
confChan chan *interceptors.ConfirmationRequest
|
||||
|
||||
dialMu sync.Mutex
|
||||
dialedAddrs []string
|
||||
}
|
||||
|
||||
type options struct {
|
||||
pinnedVersions map[string]string
|
||||
}
|
||||
|
||||
type Option func(*options)
|
||||
|
||||
// WithPinnedVersions seeds the interceptor's pinned-version context, which
|
||||
// cooldown uses to report when an explicitly requested version is blocked.
|
||||
func WithPinnedVersions(pinned map[string]string) Option {
|
||||
return func(o *options) { o.pinnedVersions = pinned }
|
||||
}
|
||||
|
||||
func New(t *testing.T, opts ...Option) *Harness {
|
||||
t.Helper()
|
||||
|
||||
var o options
|
||||
for _, opt := range opts {
|
||||
opt(&o)
|
||||
}
|
||||
|
||||
caCert, err := certmanager.GenerateCA(certmanager.DefaultCertManagerConfig())
|
||||
require.NoError(t, err)
|
||||
|
||||
certMgr, err := certmanager.NewCertificateManagerWithCA(caCert, certmanager.DefaultCertManagerConfig())
|
||||
require.NoError(t, err)
|
||||
|
||||
registry := newRegistry()
|
||||
rec := newAnalyzerRecorder()
|
||||
confirm := newConfirmController()
|
||||
stats := interceptors.NewAnalysisStatsCollector()
|
||||
|
||||
malysisAnalyzer, err := analyzer.NewMalysisQueryAnalyzerWithClient(
|
||||
&stubAnalyzerClient{rec: rec}, analyzer.MalysisQueryAnalyzerConfig{}, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
confChan := make(chan *interceptors.ConfirmationRequest, 10)
|
||||
go interceptors.HandleConfirmationRequests(confChan, confirm.interaction(), nil)
|
||||
|
||||
factory := interceptors.NewInterceptorFactory(
|
||||
malysisAnalyzer,
|
||||
interceptors.NewInMemoryAnalysisCache(),
|
||||
stats,
|
||||
confChan,
|
||||
interceptors.InterceptorContext{PinnedVersions: o.pinnedVersions},
|
||||
)
|
||||
|
||||
interceptorList := []proxy.Interceptor{interceptors.NewAuditLoggerInterceptor()}
|
||||
for _, eco := range []packagev1.Ecosystem{packagev1.Ecosystem_ECOSYSTEM_NPM, packagev1.Ecosystem_ECOSYSTEM_PYPI} {
|
||||
ic, ierr := factory.CreateInterceptor(eco)
|
||||
require.NoError(t, ierr)
|
||||
interceptorList = append(interceptorList, ic)
|
||||
}
|
||||
|
||||
h := &Harness{
|
||||
t: t,
|
||||
Registry: registry,
|
||||
Analyzer: rec,
|
||||
Confirm: confirm,
|
||||
stats: stats,
|
||||
confChan: confChan,
|
||||
}
|
||||
|
||||
h.proxy = buildProxy(t, certMgr, registry.addr(), interceptorList, h.recordDial)
|
||||
|
||||
caPool := x509.NewCertPool()
|
||||
caPool.AddCert(caCert.X509Cert)
|
||||
|
||||
proxyURL, err := url.Parse("http://" + h.proxy.Address())
|
||||
require.NoError(t, err)
|
||||
|
||||
h.client = &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyURL(proxyURL),
|
||||
TLSClientConfig: &tls.Config{RootCAs: caPool},
|
||||
},
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
func buildProxy(t *testing.T, certMgr certmanager.CertificateManager, upstreamAddr string, interceptorList []proxy.Interceptor, recordDial func(string)) proxy.ProxyServer {
|
||||
t.Helper()
|
||||
|
||||
cfg := proxy.DefaultProxyConfig()
|
||||
cfg.CertManager = certMgr
|
||||
cfg.Interceptors = interceptorList
|
||||
|
||||
// All upstream connections — MITM'd round-trips and CONNECT tunnels for
|
||||
// non-MITM hosts alike — terminate at the mock registry, so no test reaches
|
||||
// the network regardless of the hostname being proxied.
|
||||
cfg.UpstreamDialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
recordDial(addr)
|
||||
return (&net.Dialer{}).DialContext(ctx, network, upstreamAddr)
|
||||
}
|
||||
// Test-only: the mock's self-signed cert cannot match the real registry SNIs
|
||||
// the proxy presents upstream, so verification is skipped for this in-process
|
||||
// hop (the same approach proxy/scale_test.go uses).
|
||||
cfg.UpstreamTLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec G402
|
||||
|
||||
server, err := proxy.NewProxyServer(cfg)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, server.Start())
|
||||
return server
|
||||
}
|
||||
|
||||
// Close stops the proxy first so no interceptor can send on the confirmation
|
||||
// channel after it is closed.
|
||||
func (h *Harness) Close() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = h.proxy.Stop(ctx)
|
||||
close(h.confChan)
|
||||
h.Registry.close()
|
||||
}
|
||||
|
||||
func (h *Harness) Npm() NpmDriver { return NpmDriver{h: h} }
|
||||
func (h *Harness) Pypi() PypiDriver { return PypiDriver{h: h} }
|
||||
|
||||
func (h *Harness) Stats() interceptors.AnalysisStats { return h.stats.GetStats() }
|
||||
|
||||
func (h *Harness) BlockedPackages() []*analyzer.PackageVersionAnalysisResult {
|
||||
return h.stats.GetBlockedPackages()
|
||||
}
|
||||
|
||||
func (h *Harness) CooldownBlocks() []models.CooldownBlock { return h.stats.GetCooldownBlocks() }
|
||||
|
||||
func (h *Harness) recordDial(addr string) {
|
||||
h.dialMu.Lock()
|
||||
defer h.dialMu.Unlock()
|
||||
h.dialedAddrs = append(h.dialedAddrs, addr)
|
||||
}
|
||||
|
||||
// DialedAddrs returns the upstream addresses the proxy was asked to connect to,
|
||||
// before redirection to the mock. A non-MITM host appearing here proves its
|
||||
// CONNECT tunnel went through the override rather than the real network.
|
||||
func (h *Harness) DialedAddrs() []string {
|
||||
h.dialMu.Lock()
|
||||
defer h.dialMu.Unlock()
|
||||
out := make([]string, len(h.dialedAddrs))
|
||||
copy(out, h.dialedAddrs)
|
||||
return out
|
||||
}
|
||||
|
||||
// RawClient returns an HTTP client wired through the proxy and trusting the MITM
|
||||
// CA, for edge cases the install drivers do not model.
|
||||
func (h *Harness) RawClient() *http.Client { return h.client }
|
||||
|
||||
func (h *Harness) get(rawURL string, headers map[string]string) RequestOutcome {
|
||||
h.t.Helper()
|
||||
|
||||
out := RequestOutcome{URL: rawURL}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
out.Err = err
|
||||
return out
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := h.client.Do(req)
|
||||
if err != nil {
|
||||
out.Err = err
|
||||
return out
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
out.Err = err
|
||||
return out
|
||||
}
|
||||
|
||||
out.StatusCode = resp.StatusCode
|
||||
out.Blocked = resp.StatusCode == http.StatusForbidden
|
||||
out.Body = string(body)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
package proxye2e
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/safedep/pmg/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func recent() time.Time { return time.Now().Add(-24 * time.Hour) }
|
||||
func old() time.Time { return time.Now().Add(-100 * 24 * time.Hour) }
|
||||
|
||||
func cooldownEnabled(days int) func(rc *config.RuntimeConfig) {
|
||||
return func(rc *config.RuntimeConfig) {
|
||||
rc.Config.DependencyCooldown = config.DependencyCooldownConfig{Enabled: true, Days: days}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyFlow_Npm(t *testing.T) {
|
||||
RunCases(t, []TestCase{
|
||||
{
|
||||
Name: "clean package is analyzed and allowed",
|
||||
Setup: func(h *Harness) {
|
||||
h.Registry.AddNpm(NpmPackage{Name: "left-pad", DistTagLatest: "1.0.0",
|
||||
Versions: []NpmVersion{{Version: "1.0.0", PublishedAt: old()}}})
|
||||
h.Analyzer.SetNpm("left-pad", "1.0.0", Clean())
|
||||
},
|
||||
Exec: func(h *Harness) ExecResult { return h.Npm().Install("left-pad", "1.0.0") },
|
||||
Assert: func(t *testing.T, h *Harness, res ExecResult) {
|
||||
assert.False(t, res.Blocked())
|
||||
assert.Equal(t, 1, h.Analyzer.AnalyzedCount("left-pad", "1.0.0"))
|
||||
assert.True(t, h.Registry.DownloadedTarball("left-pad", "1.0.0"))
|
||||
assert.GreaterOrEqual(t, h.Stats().AllowedCount, 1)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "verified malware is blocked before download",
|
||||
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.False(t, h.Registry.DownloadedTarball("evil", "1.0.0"))
|
||||
assert.Len(t, h.BlockedPackages(), 1)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "suspicious package blocked when user declines",
|
||||
Setup: func(h *Harness) {
|
||||
h.Registry.AddNpm(NpmPackage{Name: "maybe", DistTagLatest: "1.0.0",
|
||||
Versions: []NpmVersion{{Version: "1.0.0", PublishedAt: old()}}})
|
||||
h.Analyzer.SetNpm("maybe", "1.0.0", Suspicious())
|
||||
h.Confirm.AutoDeny()
|
||||
},
|
||||
Exec: func(h *Harness) ExecResult { return h.Npm().Install("maybe", "1.0.0") },
|
||||
Assert: func(t *testing.T, h *Harness, res ExecResult) {
|
||||
assert.True(t, res.Blocked())
|
||||
assert.Len(t, h.Confirm.Prompts(), 1)
|
||||
assert.False(t, h.Registry.DownloadedTarball("maybe", "1.0.0"))
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "suspicious package allowed when user confirms",
|
||||
Setup: func(h *Harness) {
|
||||
h.Registry.AddNpm(NpmPackage{Name: "maybe", DistTagLatest: "1.0.0",
|
||||
Versions: []NpmVersion{{Version: "1.0.0", PublishedAt: old()}}})
|
||||
h.Analyzer.SetNpm("maybe", "1.0.0", Suspicious())
|
||||
h.Confirm.AutoApprove()
|
||||
},
|
||||
Exec: func(h *Harness) ExecResult { return h.Npm().Install("maybe", "1.0.0") },
|
||||
Assert: func(t *testing.T, h *Harness, res ExecResult) {
|
||||
assert.False(t, res.Blocked())
|
||||
assert.Len(t, h.Confirm.Prompts(), 1)
|
||||
assert.True(t, h.Registry.DownloadedTarball("maybe", "1.0.0"))
|
||||
assert.GreaterOrEqual(t, h.Stats().ConfirmedCount, 1)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "paranoid mode blocks suspicious without prompting",
|
||||
Config: func(rc *config.RuntimeConfig) { rc.Config.Paranoid = true },
|
||||
Setup: func(h *Harness) {
|
||||
h.Registry.AddNpm(NpmPackage{Name: "maybe", DistTagLatest: "1.0.0",
|
||||
Versions: []NpmVersion{{Version: "1.0.0", PublishedAt: old()}}})
|
||||
h.Analyzer.SetNpm("maybe", "1.0.0", Suspicious())
|
||||
},
|
||||
Exec: func(h *Harness) ExecResult { return h.Npm().Install("maybe", "1.0.0") },
|
||||
Assert: func(t *testing.T, h *Harness, res ExecResult) {
|
||||
assert.True(t, res.Blocked())
|
||||
assert.Empty(t, h.Confirm.Prompts())
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "cooldown strips in-window version from metadata",
|
||||
Config: cooldownEnabled(7),
|
||||
Setup: func(h *Harness) {
|
||||
h.Registry.AddNpm(NpmPackage{Name: "left-pad", DistTagLatest: "2.0.0", Versions: []NpmVersion{
|
||||
{Version: "1.0.0", PublishedAt: old()},
|
||||
{Version: "2.0.0", PublishedAt: recent()},
|
||||
}})
|
||||
h.Analyzer.SetNpm("left-pad", "1.0.0", Clean())
|
||||
h.Analyzer.SetNpm("left-pad", "2.0.0", Clean())
|
||||
},
|
||||
Exec: func(h *Harness) ExecResult { return h.Npm().Install("left-pad", "2.0.0") },
|
||||
Assert: func(t *testing.T, h *Harness, res ExecResult) {
|
||||
meta := h.Npm().FetchMetadata("left-pad")
|
||||
assert.False(t, meta.HasVersion("2.0.0"), "in-window version must be stripped")
|
||||
assert.True(t, meta.HasVersion("1.0.0"), "out-of-window version must survive")
|
||||
assert.False(t, h.Registry.DownloadedTarball("left-pad", "2.0.0"))
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "cooldown records a blocked pinned version",
|
||||
Config: cooldownEnabled(7),
|
||||
PinnedVersions: map[string]string{"left-pad": "2.0.0"},
|
||||
Setup: func(h *Harness) {
|
||||
h.Registry.AddNpm(NpmPackage{Name: "left-pad", DistTagLatest: "2.0.0", Versions: []NpmVersion{
|
||||
{Version: "1.0.0", PublishedAt: old()},
|
||||
{Version: "2.0.0", PublishedAt: recent()},
|
||||
}})
|
||||
},
|
||||
Exec: func(h *Harness) ExecResult { return h.Npm().Install("left-pad", "2.0.0") },
|
||||
Assert: func(t *testing.T, h *Harness, res ExecResult) {
|
||||
assert.GreaterOrEqual(t, h.Stats().CooldownBlockedCount, 1)
|
||||
blocks := h.CooldownBlocks()
|
||||
var found bool
|
||||
for _, b := range blocks {
|
||||
if b.Name == "left-pad" && b.Version == "2.0.0" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "pinned in-window version should be recorded as a cooldown block")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "cooldown allows out-of-window version",
|
||||
Config: cooldownEnabled(7),
|
||||
Setup: func(h *Harness) {
|
||||
h.Registry.AddNpm(NpmPackage{Name: "left-pad", DistTagLatest: "1.0.0",
|
||||
Versions: []NpmVersion{{Version: "1.0.0", PublishedAt: old()}}})
|
||||
h.Analyzer.SetNpm("left-pad", "1.0.0", Clean())
|
||||
},
|
||||
Exec: func(h *Harness) ExecResult { return h.Npm().Install("left-pad", "1.0.0") },
|
||||
Assert: func(t *testing.T, h *Harness, res ExecResult) {
|
||||
assert.False(t, res.Blocked())
|
||||
assert.True(t, h.Registry.DownloadedTarball("left-pad", "1.0.0"))
|
||||
assert.Equal(t, 1, h.Analyzer.AnalyzedCount("left-pad", "1.0.0"))
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "cooldown skip waives wait but malware still blocks",
|
||||
Config: func(rc *config.RuntimeConfig) {
|
||||
rc.Config.DependencyCooldown = config.DependencyCooldownConfig{
|
||||
Enabled: true, Days: 7,
|
||||
Skip: []config.TrustedPackage{{Purl: "pkg:npm/left-pad@2.0.0"}},
|
||||
}
|
||||
},
|
||||
Setup: func(h *Harness) {
|
||||
h.Registry.AddNpm(NpmPackage{Name: "left-pad", DistTagLatest: "2.0.0",
|
||||
Versions: []NpmVersion{{Version: "2.0.0", PublishedAt: recent()}}})
|
||||
h.Analyzer.SetNpm("left-pad", "2.0.0", VerifiedMalware())
|
||||
},
|
||||
Exec: func(h *Harness) ExecResult { return h.Npm().Install("left-pad", "2.0.0") },
|
||||
Assert: func(t *testing.T, h *Harness, res ExecResult) {
|
||||
meta := h.Npm().FetchMetadata("left-pad")
|
||||
assert.True(t, meta.HasVersion("2.0.0"), "skip-listed version must survive cooldown")
|
||||
assert.True(t, res.Blocked(), "malware analysis still applies to a cooldown-skipped version")
|
||||
assert.GreaterOrEqual(t, h.Analyzer.AnalyzedCount("left-pad", "2.0.0"), 1)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "cooldown skip fast-tracks a clean in-window version",
|
||||
Config: func(rc *config.RuntimeConfig) {
|
||||
rc.Config.DependencyCooldown = config.DependencyCooldownConfig{
|
||||
Enabled: true, Days: 7,
|
||||
Skip: []config.TrustedPackage{{Purl: "pkg:npm/left-pad@2.0.0"}},
|
||||
}
|
||||
},
|
||||
Setup: func(h *Harness) {
|
||||
h.Registry.AddNpm(NpmPackage{Name: "left-pad", DistTagLatest: "2.0.0", Versions: []NpmVersion{
|
||||
{Version: "1.0.0", PublishedAt: old()},
|
||||
{Version: "2.0.0", PublishedAt: recent()},
|
||||
}})
|
||||
h.Analyzer.SetNpm("left-pad", "2.0.0", Clean())
|
||||
},
|
||||
Exec: func(h *Harness) ExecResult { return h.Npm().Install("left-pad", "2.0.0") },
|
||||
Assert: func(t *testing.T, h *Harness, res ExecResult) {
|
||||
assert.False(t, res.Blocked())
|
||||
meta := h.Npm().FetchMetadata("left-pad")
|
||||
assert.True(t, meta.HasVersion("2.0.0"), "skip-listed in-window version must survive cooldown")
|
||||
assert.True(t, h.Registry.DownloadedTarball("left-pad", "2.0.0"))
|
||||
assert.GreaterOrEqual(t, h.Analyzer.AnalyzedCount("left-pad", "2.0.0"), 1, "skip waives cooldown only, not malware analysis")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "cooldown whole-package skip keeps every version",
|
||||
Config: func(rc *config.RuntimeConfig) {
|
||||
rc.Config.DependencyCooldown = config.DependencyCooldownConfig{
|
||||
Enabled: true, Days: 7,
|
||||
Skip: []config.TrustedPackage{{Purl: "pkg:npm/left-pad"}},
|
||||
}
|
||||
},
|
||||
Setup: func(h *Harness) {
|
||||
h.Registry.AddNpm(NpmPackage{Name: "left-pad", DistTagLatest: "2.0.0", Versions: []NpmVersion{
|
||||
{Version: "1.0.0", PublishedAt: recent()},
|
||||
{Version: "2.0.0", PublishedAt: recent()},
|
||||
}})
|
||||
h.Analyzer.SetNpm("left-pad", "2.0.0", Clean())
|
||||
},
|
||||
Exec: func(h *Harness) ExecResult { return h.Npm().Install("left-pad", "2.0.0") },
|
||||
Assert: func(t *testing.T, h *Harness, res ExecResult) {
|
||||
assert.False(t, res.Blocked())
|
||||
meta := h.Npm().FetchMetadata("left-pad")
|
||||
assert.True(t, meta.HasVersion("1.0.0"), "version-less skip must keep all in-window versions")
|
||||
assert.True(t, meta.HasVersion("2.0.0"), "version-less skip must keep all in-window versions")
|
||||
assert.True(t, h.Registry.DownloadedTarball("left-pad", "2.0.0"))
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "trusted package skips analysis entirely",
|
||||
Config: func(rc *config.RuntimeConfig) {
|
||||
rc.Config.TrustedPackages = []config.TrustedPackage{{Purl: "pkg:npm/left-pad"}}
|
||||
},
|
||||
Setup: func(h *Harness) {
|
||||
h.Registry.AddNpm(NpmPackage{Name: "left-pad", DistTagLatest: "1.0.0",
|
||||
Versions: []NpmVersion{{Version: "1.0.0", PublishedAt: old()}}})
|
||||
h.Analyzer.SetNpm("left-pad", "1.0.0", VerifiedMalware())
|
||||
},
|
||||
Exec: func(h *Harness) ExecResult { return h.Npm().Install("left-pad", "1.0.0") },
|
||||
Assert: func(t *testing.T, h *Harness, res ExecResult) {
|
||||
assert.False(t, res.Blocked())
|
||||
assert.Equal(t, 0, h.Analyzer.AnalyzedCount("left-pad", "1.0.0"))
|
||||
assert.True(t, h.Registry.DownloadedTarball("left-pad", "1.0.0"))
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "trusted package waives both cooldown and malware analysis",
|
||||
Config: func(rc *config.RuntimeConfig) {
|
||||
rc.Config.TrustedPackages = []config.TrustedPackage{{Purl: "pkg:npm/left-pad"}}
|
||||
rc.Config.DependencyCooldown = config.DependencyCooldownConfig{Enabled: true, Days: 7}
|
||||
},
|
||||
Setup: func(h *Harness) {
|
||||
h.Registry.AddNpm(NpmPackage{Name: "left-pad", DistTagLatest: "1.0.0",
|
||||
Versions: []NpmVersion{{Version: "1.0.0", PublishedAt: recent()}}})
|
||||
h.Analyzer.SetNpm("left-pad", "1.0.0", VerifiedMalware())
|
||||
},
|
||||
Exec: func(h *Harness) ExecResult { return h.Npm().Install("left-pad", "1.0.0") },
|
||||
Assert: func(t *testing.T, h *Harness, res ExecResult) {
|
||||
assert.False(t, res.Blocked())
|
||||
meta := h.Npm().FetchMetadata("left-pad")
|
||||
assert.True(t, meta.HasVersion("1.0.0"), "trusted package must bypass an active cooldown window")
|
||||
assert.Equal(t, 0, h.Analyzer.AnalyzedCount("left-pad", "1.0.0"), "trusted package must bypass malware analysis")
|
||||
assert.True(t, h.Registry.DownloadedTarball("left-pad", "1.0.0"))
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "insecure mode bypasses analysis",
|
||||
Config: func(rc *config.RuntimeConfig) { rc.InsecureInstallation = true },
|
||||
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.False(t, res.Blocked())
|
||||
assert.Equal(t, 0, h.Analyzer.AnalyzedCount("evil", "1.0.0"))
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "analyzer NotFound allows the package",
|
||||
Setup: func(h *Harness) {
|
||||
h.Registry.AddNpm(NpmPackage{Name: "unknown", DistTagLatest: "1.0.0",
|
||||
Versions: []NpmVersion{{Version: "1.0.0", PublishedAt: old()}}})
|
||||
h.Analyzer.SetNpm("unknown", "1.0.0", NotFound())
|
||||
},
|
||||
Exec: func(h *Harness) ExecResult { return h.Npm().Install("unknown", "1.0.0") },
|
||||
Assert: func(t *testing.T, h *Harness, res ExecResult) {
|
||||
assert.False(t, res.Blocked())
|
||||
assert.True(t, h.Registry.DownloadedTarball("unknown", "1.0.0"))
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "analyzer error fails open and allows",
|
||||
Setup: func(h *Harness) {
|
||||
h.Registry.AddNpm(NpmPackage{Name: "flaky", DistTagLatest: "1.0.0",
|
||||
Versions: []NpmVersion{{Version: "1.0.0", PublishedAt: old()}}})
|
||||
h.Analyzer.SetNpm("flaky", "1.0.0", ServerError())
|
||||
},
|
||||
Exec: func(h *Harness) ExecResult { return h.Npm().Install("flaky", "1.0.0") },
|
||||
Assert: func(t *testing.T, h *Harness, res ExecResult) {
|
||||
assert.False(t, res.Blocked())
|
||||
assert.True(t, h.Registry.DownloadedTarball("flaky", "1.0.0"))
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// A host the interceptors observe but never MITM (test.pypi.org) is tunneled via
|
||||
// CONNECT. The override must route that tunnel to the mock so no proxy path
|
||||
// escapes to the real network.
|
||||
func TestProxyFlow_NonMitmHostStaysHermetic(t *testing.T) {
|
||||
applyConfig(t, nil)
|
||||
|
||||
h := New(t)
|
||||
defer h.Close()
|
||||
|
||||
_, _ = h.RawClient().Get("https://test.pypi.org/simple/requests/")
|
||||
|
||||
assert.Contains(t, h.DialedAddrs(), "test.pypi.org:443",
|
||||
"non-MITM CONNECT tunnel must be dialed through the mock override")
|
||||
}
|
||||
|
||||
func TestProxyFlow_Pypi(t *testing.T) {
|
||||
RunCases(t, []TestCase{
|
||||
{
|
||||
Name: "clean package is analyzed and allowed",
|
||||
Setup: func(h *Harness) {
|
||||
h.Registry.AddPypi(PypiPackage{Name: "requests",
|
||||
Versions: []PypiVersion{{Version: "2.0.0", PublishedAt: old()}}})
|
||||
h.Analyzer.SetPypi("requests", "2.0.0", Clean())
|
||||
},
|
||||
Exec: func(h *Harness) ExecResult { return h.Pypi().Install("requests", "2.0.0") },
|
||||
Assert: func(t *testing.T, h *Harness, res ExecResult) {
|
||||
assert.False(t, res.Blocked())
|
||||
assert.Equal(t, 1, h.Analyzer.AnalyzedCount("requests", "2.0.0"))
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "verified malware is blocked",
|
||||
Setup: func(h *Harness) {
|
||||
h.Registry.AddPypi(PypiPackage{Name: "evil",
|
||||
Versions: []PypiVersion{{Version: "1.0.0", PublishedAt: old()}}})
|
||||
h.Analyzer.SetPypi("evil", "1.0.0", VerifiedMalware())
|
||||
},
|
||||
Exec: func(h *Harness) ExecResult { return h.Pypi().Install("evil", "1.0.0") },
|
||||
Assert: func(t *testing.T, h *Harness, res ExecResult) {
|
||||
assert.True(t, res.Blocked())
|
||||
assert.Len(t, h.BlockedPackages(), 1)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "cooldown strips in-window version with name normalization",
|
||||
Config: cooldownEnabled(7),
|
||||
Setup: func(h *Harness) {
|
||||
h.Registry.AddPypi(PypiPackage{Name: "Flask_Thing", Versions: []PypiVersion{
|
||||
{Version: "1.0.0", PublishedAt: old()},
|
||||
{Version: "2.0.0", PublishedAt: recent()},
|
||||
}})
|
||||
},
|
||||
Exec: func(h *Harness) ExecResult { return h.Pypi().Install("Flask_Thing", "2.0.0") },
|
||||
Assert: func(t *testing.T, h *Harness, res ExecResult) {
|
||||
simple := h.Pypi().FetchSimple("Flask_Thing")
|
||||
assert.False(t, simple.HasVersion("Flask_Thing", "2.0.0"), "in-window version must be stripped")
|
||||
assert.True(t, simple.HasVersion("Flask_Thing", "1.0.0"), "out-of-window version must survive")
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package proxye2e
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type NpmVersion struct {
|
||||
Version string
|
||||
PublishedAt time.Time
|
||||
Tarball []byte
|
||||
}
|
||||
|
||||
type NpmPackage struct {
|
||||
Name string
|
||||
DistTagLatest string
|
||||
Versions []NpmVersion
|
||||
}
|
||||
|
||||
type PypiVersion struct {
|
||||
Version string
|
||||
PublishedAt time.Time
|
||||
Bytes []byte
|
||||
}
|
||||
|
||||
type PypiPackage struct {
|
||||
Name string
|
||||
Versions []PypiVersion
|
||||
}
|
||||
|
||||
type RecordedRequest struct {
|
||||
Host string
|
||||
Method string
|
||||
Path string
|
||||
}
|
||||
|
||||
// Registry is an in-process stand-in for the npm and PyPI registries. The proxy
|
||||
// upstream is redirected here, so it answers for every registry hostname and
|
||||
// records each request for routing assertions.
|
||||
type Registry struct {
|
||||
mu sync.Mutex
|
||||
npm map[string]NpmPackage
|
||||
pypi map[string]PypiPackage
|
||||
requests []RecordedRequest
|
||||
server *httptest.Server
|
||||
}
|
||||
|
||||
func newRegistry() *Registry {
|
||||
r := &Registry{
|
||||
npm: map[string]NpmPackage{},
|
||||
pypi: map[string]PypiPackage{},
|
||||
}
|
||||
r.server = httptest.NewTLSServer(http.HandlerFunc(r.serve))
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Registry) addr() string { return r.server.Listener.Addr().String() }
|
||||
|
||||
func (r *Registry) close() { r.server.Close() }
|
||||
|
||||
func (r *Registry) AddNpm(pkg NpmPackage) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.npm[pkg.Name] = pkg
|
||||
}
|
||||
|
||||
func (r *Registry) AddPypi(pkg PypiPackage) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.pypi[normalizePypiName(pkg.Name)] = pkg
|
||||
}
|
||||
|
||||
// Requests returns every request the proxy forwarded upstream, in order.
|
||||
func (r *Registry) Requests() []RecordedRequest {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]RecordedRequest, len(r.requests))
|
||||
copy(out, r.requests)
|
||||
return out
|
||||
}
|
||||
|
||||
// DownloadedTarball reports whether a tarball for the given npm package version
|
||||
// was fetched from the registry.
|
||||
func (r *Registry) DownloadedTarball(name, version string) bool {
|
||||
want := fmt.Sprintf("/%s/-/%s-%s.tgz", name, name, version)
|
||||
for _, req := range r.Requests() {
|
||||
if req.Path == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *Registry) serve(w http.ResponseWriter, req *http.Request) {
|
||||
host := hostOnly(req.Host)
|
||||
|
||||
r.mu.Lock()
|
||||
r.requests = append(r.requests, RecordedRequest{Host: host, Method: req.Method, Path: req.URL.Path})
|
||||
r.mu.Unlock()
|
||||
|
||||
switch host {
|
||||
case "registry.npmjs.org", "registry.yarnpkg.com":
|
||||
r.serveNpm(w, req)
|
||||
case "pypi.org":
|
||||
r.servePypiSimple(w, req)
|
||||
case "files.pythonhosted.org":
|
||||
r.servePypiFile(w, req)
|
||||
default:
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) serveNpm(w http.ResponseWriter, req *http.Request) {
|
||||
path := strings.Trim(req.URL.Path, "/")
|
||||
|
||||
if strings.Contains(path, "/-/") {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = w.Write([]byte("e2e-tarball"))
|
||||
return
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
pkg, ok := r.npm[path]
|
||||
r.mu.Unlock()
|
||||
if !ok {
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(buildPackument(pkg))
|
||||
}
|
||||
|
||||
func (r *Registry) servePypiSimple(w http.ResponseWriter, req *http.Request) {
|
||||
name := strings.Trim(strings.TrimPrefix(req.URL.Path, "/simple/"), "/")
|
||||
|
||||
r.mu.Lock()
|
||||
pkg, ok := r.pypi[normalizePypiName(name)]
|
||||
r.mu.Unlock()
|
||||
if !ok {
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", pypiSimpleContentType)
|
||||
_, _ = w.Write(buildPypiSimple(pkg))
|
||||
}
|
||||
|
||||
func (r *Registry) servePypiFile(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = w.Write([]byte("e2e-wheel"))
|
||||
}
|
||||
|
||||
const pypiSimpleContentType = "application/vnd.pypi.simple.v1+json"
|
||||
|
||||
func buildPackument(pkg NpmPackage) []byte {
|
||||
versions := map[string]any{}
|
||||
times := map[string]string{}
|
||||
for _, v := range pkg.Versions {
|
||||
versions[v.Version] = map[string]any{
|
||||
"name": pkg.Name,
|
||||
"version": v.Version,
|
||||
"dist": map[string]any{
|
||||
"tarball": fmt.Sprintf("https://registry.npmjs.org/%s/-/%s-%s.tgz", pkg.Name, pkg.Name, v.Version),
|
||||
},
|
||||
}
|
||||
times[v.Version] = v.PublishedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
latest := pkg.DistTagLatest
|
||||
if latest == "" && len(pkg.Versions) > 0 {
|
||||
latest = pkg.Versions[len(pkg.Versions)-1].Version
|
||||
}
|
||||
|
||||
doc := map[string]any{
|
||||
"name": pkg.Name,
|
||||
"dist-tags": map[string]string{"latest": latest},
|
||||
"versions": versions,
|
||||
"time": times,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(doc)
|
||||
return body
|
||||
}
|
||||
|
||||
func buildPypiSimple(pkg PypiPackage) []byte {
|
||||
norm := normalizePypiName(pkg.Name)
|
||||
files := []map[string]any{}
|
||||
for _, v := range pkg.Versions {
|
||||
filename := fmt.Sprintf("%s-%s.tar.gz", norm, v.Version)
|
||||
files = append(files, map[string]any{
|
||||
"filename": filename,
|
||||
"url": fmt.Sprintf("https://files.pythonhosted.org/packages/source/%c/%s/%s", norm[0], norm, filename),
|
||||
"hashes": map[string]string{},
|
||||
"upload-time": v.PublishedAt.UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
doc := map[string]any{
|
||||
"meta": map[string]any{"api-version": "1.0"},
|
||||
"name": norm,
|
||||
"files": files,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(doc)
|
||||
return body
|
||||
}
|
||||
|
||||
func hostOnly(host string) string {
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
return h
|
||||
}
|
||||
return host
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package proxye2e
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/safedep/pmg/config"
|
||||
)
|
||||
|
||||
// TestCase is one end-to-end scenario. Config mutates the global PMG config for
|
||||
// the case; Setup registers fixtures and verdicts; Exec drives traffic; Assert
|
||||
// verifies the outcome.
|
||||
type TestCase struct {
|
||||
Name string
|
||||
PinnedVersions map[string]string
|
||||
Config func(rc *config.RuntimeConfig)
|
||||
Setup func(h *Harness)
|
||||
Exec func(h *Harness) ExecResult
|
||||
Assert func(t *testing.T, h *Harness, result ExecResult)
|
||||
}
|
||||
|
||||
// RunCases runs each case serially. Serial execution is required because the
|
||||
// interceptors read the global config singleton at request time, which the
|
||||
// runner mutates per case.
|
||||
func RunCases(t *testing.T, cases []TestCase) {
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
applyConfig(t, tc.Config)
|
||||
|
||||
h := New(t, WithPinnedVersions(tc.PinnedVersions))
|
||||
defer h.Close()
|
||||
|
||||
if tc.Setup != nil {
|
||||
tc.Setup(h)
|
||||
}
|
||||
|
||||
var result ExecResult
|
||||
if tc.Exec != nil {
|
||||
result = tc.Exec(h)
|
||||
}
|
||||
|
||||
if tc.Assert != nil {
|
||||
tc.Assert(t, h, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// applyConfig resets the security-relevant config fields to a known hermetic
|
||||
// baseline, applies the case override, then restores the original on cleanup so
|
||||
// a developer's on-disk config never leaks into a case.
|
||||
func applyConfig(t *testing.T, override func(rc *config.RuntimeConfig)) {
|
||||
t.Helper()
|
||||
|
||||
rc := config.Get()
|
||||
saved := *rc
|
||||
t.Cleanup(func() { *rc = saved })
|
||||
|
||||
rc.InsecureInstallation = false
|
||||
rc.Config.Paranoid = false
|
||||
rc.Config.TrustedPackages = nil
|
||||
rc.Config.DependencyCooldown = config.DependencyCooldownConfig{}
|
||||
|
||||
if override != nil {
|
||||
override(rc)
|
||||
}
|
||||
|
||||
if err := config.PreprocessTrustedPackages(&rc.Config); err != nil {
|
||||
t.Fatalf("failed to preprocess trusted packages: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user