diff --git a/examples/proxy/main.go b/examples/proxy/main.go index a7bcff6..6c91d6f 100644 --- a/examples/proxy/main.go +++ b/examples/proxy/main.go @@ -9,6 +9,7 @@ import ( "time" "github.com/safedep/dry/log" + "github.com/safedep/pmg/analyzer" "github.com/safedep/pmg/proxy" "github.com/safedep/pmg/proxy/certmanager" ) @@ -49,12 +50,18 @@ func main() { log.Fatalf("Failed to create cert manager: %v", err) } + malysisQueryAnalyzer, err := analyzer.NewMalysisQueryAnalyzer(analyzer.MalysisQueryAnalyzerConfig{}) + if err != nil { + fmt.Println("failed to initialise malysis query analyser: ", err) + os.Exit(0) + } + // Create proxy with certificate manager and logging interceptor proxyConfig := &proxy.ProxyConfig{ ListenAddr: listenAddr, CertManager: certMgr, EnableMITM: true, - Interceptors: []proxy.Interceptor{newLoggingInterceptor()}, + Interceptors: []proxy.Interceptor{newMalysisInterceptor(malysisQueryAnalyzer)}, ConnectTimeout: connectTimeout, RequestTimeout: requestTimeout, } diff --git a/examples/proxy/malysis_interceptor.go b/examples/proxy/malysis_interceptor.go new file mode 100644 index 0000000..b6e8675 --- /dev/null +++ b/examples/proxy/malysis_interceptor.go @@ -0,0 +1,162 @@ +package main + +import ( + "context" + "fmt" + "net/url" + "path" + "regexp" + "slices" + "strings" + + packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" + "github.com/safedep/pmg/analyzer" + "github.com/safedep/pmg/proxy" +) + +type malysisInterceptor struct { + domains []string + malysisQueryAnalyzer analyzer.PackageVersionAnalyzer +} + +func newMalysisInterceptor(analyzer analyzer.PackageVersionAnalyzer) *malysisInterceptor { + return &malysisInterceptor{ + malysisQueryAnalyzer: analyzer, + domains: []string{ + "registry.npmjs.org", + "registry.yarnpkg.com", + "pypi.org", + "files.pythonhosted.org", + }, + } +} + +func (m *malysisInterceptor) Name() string { + return "logging-interceptor" +} + +func (m *malysisInterceptor) ShouldIntercept(ctx *proxy.RequestContext) bool { + return slices.Contains(m.domains, ctx.Hostname) +} + +// parseNpmTarballURL parses tarball URLs such as +// +// https://registry.npmjs.org/send/-/send-1.2.1.tgz +// +// and returns a PackageVersion with Ecosystem_NPM, Name and Version set. +// +// The parser: +// - extracts the filename from the URL path +// - strips common extensions (.tgz, .tar.gz) +// - finds a semver-like suffix in the filename and treats it as the version +// - derives the package name as the filename without the version suffix +// - attempts to detect scoped package names from earlier path segments (e.g. @scope/pkg) +func parseNpmTarballURL(raw string) (*packagev1.PackageVersion, error) { + u, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("failed to parse url: %w", err) + } + + filename := path.Base(u.Path) // e.g. send-1.2.1.tgz + name := filename + if strings.HasSuffix(name, ".tgz") { + name = strings.TrimSuffix(name, ".tgz") + } else if strings.HasSuffix(name, ".tar.gz") { + name = strings.TrimSuffix(name, ".tar.gz") + } + + // Capture semver-like version at the end of the filename. + // Examples matched: 1.2.3, 1.2.3-beta.1, 1.2.3+meta, 1.2.3-beta.1+meta + re := regexp.MustCompile(`-(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]*)?)$`) + m := re.FindStringSubmatch(name) + if len(m) < 2 { + return nil, fmt.Errorf("version not found in filename %s", filename) + } + version := m[1] + pkgName := strings.TrimSuffix(name, "-"+version) + + // Try to detect scoped package name from the path segments, e.g. + // /@scope%2Fpkg/-/pkg-1.0.0.tgz -> segment "@scope%2Fpkg" + // decode any percent-encoded segments and prefer a decoded value that contains "/" + seg := strings.Split(u.Path, "/") + for _, s := range seg { + if strings.Contains(s, "%2F") || strings.Contains(s, "%2f") || strings.HasPrefix(s, "@") { + dec, err := url.PathUnescape(s) + if err == nil && strings.Contains(dec, "/") { + // dec is like "@scope/pkg" + pkgName = dec + break + } + if err == nil && strings.HasPrefix(dec, "@") { + // fallback: keep the decoded @scope if present + pkgName = dec + break + } + } + } + + pv := &packagev1.PackageVersion{ + Package: &packagev1.Package{ + Name: pkgName, + Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM, + }, + Version: version, + } + return pv, nil +} + +func (m *malysisInterceptor) HandleRequest(ctx *proxy.RequestContext) (*proxy.InterceptorResponse, error) { + + url := ctx.URL.String() + isTarball := strings.Contains(url, "tgz") + + if !isTarball { + return &proxy.InterceptorResponse{ + Action: proxy.ActionAllow, + }, nil + } + + // Parse the tarball URL into a PackageVersion and attach it to the request context + pv, err := parseNpmTarballURL(url) + if err != nil { + fmt.Printf("Failed to parse npm tarball [%s] with error: %s", url, err.Error()) + return &proxy.InterceptorResponse{ + Action: proxy.ActionAllow, + }, nil + } + + result, err := m.malysisQueryAnalyzer.Analyze(context.Background(), pv) + if err != nil { + fmt.Printf("failed to analyse package %s with error %s:", pv.Package.Name, err.Error()) + return &proxy.InterceptorResponse{ + Action: proxy.ActionAllow, + }, nil + } + + switch result.Action { + case analyzer.ActionAllow: + fmt.Printf("Package %s@%s -> action: ALLOW\n", pv.Package.Name, pv.Version) + return &proxy.InterceptorResponse{ + Action: proxy.ActionAllow, + }, nil + + case analyzer.ActionConfirm: + fmt.Printf("Package %s@%s -> action: CONFIRM (mapping to BLOCK)\n", pv.Package.Name, pv.Version) + return &proxy.InterceptorResponse{ + Action: proxy.ActionBlock, + }, nil + + case analyzer.ActionBlock: + fmt.Printf("Package %s@%s -> action: BLOCK\n", pv.Package.Name, pv.Version) + return &proxy.InterceptorResponse{ + Action: proxy.ActionBlock, + }, nil + + default: + fmt.Printf("Package %s@%s -> action: UNKNOWN (default to ALLOW)\n", pv.Package.Name, pv.Version) + return &proxy.InterceptorResponse{ + Action: proxy.ActionAllow, + }, nil + + } +}