Originally published on tamiz.pro.
Supply chain attacks like the October 2024 "Black October" campaign — where attackers published malicious packages to npm, PyPI, and Packagist mimicking legitimate internal dependencies — exploit a fundamental gap: most security scanning happens after dependencies are installed, not before they're approved. This tutorial walks through building a pre-ship security filter that runs in your CI pipeline, validates every dependency against multiple threat intelligence sources, and blocks suspicious packages before they ever hit your build cache.
Table of Contents
- 1. Threat Model & Architecture
- 2. Prerequisites
- 3. Core Data Structures
- 4. Package Manifest Parsers
- 5. Threat Intelligence Providers
- 6. Detection Rules Engine
- 7. CI/CD Integration
- 8. Testing the Filter
- 9. Production Hardening
- 10. Frequently Asked Questions
1. Threat Model & Architecture
What We're Detecting
The filter catches four attack classes that dominated Black October:
| Attack Class | Example | Detection Signal |
|---|---|---|
| Typosquatting | reqeusts vs requests | Levenshtein distance ≤ 2, same ecosystem |
| Dependency Confusion | Internal pkg corp-utils published publicly | Name matches internal allowlist, version exists on public registry |
| Malicious Payload | postinstall script exfiltrates env vars | Static analysis of package scripts, entropy analysis |
| Protestware / Sabotage | node-ipc wiping files in Russia/Belarus | Geo-IP checks in code, destructive FS ops |
Architecture Overview
┌─────────────────┐ ┌──────────────────┐ ┌────────────────────┐ │ CI Pipeline │────▶│ Security Filter │────▶│ Threat Intel APIs │ │ (GitHub Actions│ │ (this tool) │ │ • OSV.dev │ │ GitLab CI) │ │ │ │ • npm audit │ └─────────────────┘ └──────────────────┘ │ • PyPI Advisories │ │ │ │ • Packagist Sec │ │ ▼ └────────────────────┘ │ ┌──────────────────┐ └─────────────▶│ Policy Decision │ │ PASS / FAIL │ └──────────────────┘ The filter is a single Go binary (fast, no runtime dependencies) invoked as a CI step. It reads lockfiles, queries threat intel in parallel, applies policy, and exits non-zero on violations.
2. Prerequisites
- Go 1.22+ (for the filter binary)
- Access to CI secrets store (GitHub Actions secrets, GitLab CI variables, etc.)
- Optional: Internal package registry allowlist (JSON file)
- Optional: OSV.dev API key (higher rate limits)
# Verify Go version go version # go1.22.0+ # Clone the reference implementation git clone https://github.com/your-org/pre-ship-filter cd pre-ship-filter 3. Core Data Structures
internal/model/types.go
package model import "time" // Lockfile represents a parsed dependency lockfile // ponytail: single struct for all ecosystems; ecosystem field distinguishes // upgrade path: per-ecosystem types if fields diverge significantly type Lockfile struct { Ecosystem Ecosystem `json:"ecosystem"` Path string `json:"path"` Packages []Package `json:"packages"` ParsedAt time.Time `json:"parsed_at"` } type Ecosystem string const ( EcosystemNPM Ecosystem = "npm" EcosystemPyPI Ecosystem = "pypi" EcosystemComposer Ecosystem = "composer" ) type Package struct { Name string `json:"name"` Version string `json:"version"` ResolvedURL string `json:"resolved_url,omitempty"` Integrity string `json:"integrity,omitempty"` Dev bool `json:"dev,omitempty"` Dependencies map[string]string `json:"dependencies,omitempty"` // Ecosystem-specific fields Scripts map[string]string `json:"scripts,omitempty"` // npm Requires map[string]string `json:"requires,omitempty"` // composer Extras map[string]any `json:"extras,omitempty"` // pypi } // Finding represents a single security violation type Finding struct { Package Package `json:"package"` RuleID string `json:"rule_id"` Severity Severity `json:"severity"` Message string `json:"message"` Evidence Evidence `json:"evidence"` Remediation string `json:"remediation,omitempty"` } type Severity string const ( SeverityCritical Severity = "CRITICAL" SeverityHigh Severity = "HIGH" SeverityMedium Severity = "MEDIUM" SeverityLow Severity = "LOW" SeverityInfo Severity = "INFO" ) type Evidence struct { Source string `json:"source"` MatchedRule string `json:"matched_rule"` Details map[string]any `json:"details"` References []string `json:"references,omitempty"` } // PolicyConfig defines what constitutes a build failure type PolicyConfig struct { FailOnSeverity []Severity `json:"fail_on_severity"` IgnoreDevDeps bool `json:"ignore_dev_deps"` AllowlistPath string `json:"allowlist_path,omitempty"` CustomRulesPath string `json:"custom_rules_path,omitempty"` } 4. Package Manifest Parsers
Each ecosystem has a parser implementing a common interface.
internal/parser/interface.go
package parser import ( "github.com/your-org/pre-ship-filter/internal/model" ) // Parser extracts packages from ecosystem lockfiles type Parser interface { Parse(path string) (*model.Lockfile, error) Ecosystem() model.Ecosystem LockfileNames() []string } 4.1 npm — package-lock.json / yarn.lock / pnpm-lock.yaml
internal/parser/npm.go
package parser import ( "encoding/json" "fmt" "os" "path/filepath" "strings" "github.com/your-org/pre-ship-filter/internal/model" "gopkg.in/yaml.v3" ) type NPMParser struct{} func (NPMParser) Ecosystem() model.Ecosystem { return model.EcosystemNPM } func (NPMParser) LockfileNames() []string { return []string{"package-lock.json", "yarn.lock", "pnpm-lock.yaml"} } func (NPMParser) Parse(path string) (*model.Lockfile, error) { base := filepath.Base(path) switch base { case "package-lock.json": return parsePackageLock(path) case "yarn.lock": return parseYarnLock(path) case "pnpm-lock.yaml": return parsePnpmLock(path) default: return nil, fmt.Errorf("unsupported npm lockfile: %s", base) } } func parsePackageLock(path string) (*model.Lockfile, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } var raw map[string]any if err := json.Unmarshal(data, &raw); err != nil { return nil, err } lf := &model.Lockfile{ Ecosystem: model.EcosystemNPM, Path: path, ParsedAt: time.Now(), } // Handle both lockfile v1 (dependencies) and v2 (packages) pkgs := raw["packages"].(map[string]any) if pkgs == nil { pkgs = raw["dependencies"].(map[string]any) } for name, info := range pkgs { if name == "" || name == "node_modules" { continue } pkgInfo := info.(map[string]any) version := pkgInfo["version"].(string) p := model.Package{ Name: strings.TrimPrefix(name, "node_modules/"), Version: version, Dev: pkgInfo["dev"] == true || pkgInfo["devOptional"] == true, } if scripts, ok := pkgInfo["scripts"].(map[string]any); ok { p.Scripts = make(map[string]string) for k, v := range scripts { p.Scripts[k] = v.(string) } } if resolved, ok := pkgInfo["resolved"].(string); ok { p.ResolvedURL = resolved } if integrity, ok := pkgInfo["integrity"].(string); ok { p.Integrity = integrity } lf.Packages = append(lf.Packages, p) } return lf, nil } // parseYarnLock and parsePnpmLock omitted for brevity — similar pattern // using yaml.v3 for YAML-based lockfiles 4.2 PyPI — poetry.lock / requirements.txt / uv.lock
internal/parser/pypi.go
package parser import ( "encoding/json" "fmt" "os" "strings" "github.com/your-org/pre-ship-filter/internal/model" "gopkg.in/yaml.v3" ) type PyPIParser struct{} func (PyPIParser) Ecosystem() model.Ecosystem { return model.EcosystemPyPI } func (PyPIParser) LockfileNames() []string { return []string{"poetry.lock", "requirements.txt", "uv.lock", "Pipfile.lock"} } func (p PyPIParser) Parse(path string) (*model.Lockfile, error) { base := filepath.Base(path) switch base { case "poetry.lock": return parsePoetryLock(path) case "requirements.txt": return parseRequirementsTxt(path) case "uv.lock": return parseUVLock(path) case "Pipfile.lock": return parsePipfileLock(path) default: return nil, fmt.Errorf("unsupported pypi lockfile: %s", base) } } func parsePoetryLock(path string) (*model.Lockfile, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } var raw struct { Package []struct { Name string `json:"name"` Version string `json:"version"` Description string `json:"description"` Optional bool `json:"optional"` Category string `json:"category"` // main, dev } `json:"package"` } if err := yaml.Unmarshal(data, &raw); err != nil { // Try TOML (older poetry) return parsePoetryLockTOML(path) } lf := &model.Lockfile{Ecosystem: model.EcosystemPyPI, Path: path, ParsedAt: time.Now()} for _, pkg := range raw.Package { lf.Packages = append(lf.Packages, model.Package{ Name: strings.ToLower(pkg.Name), Version: pkg.Version, Dev: pkg.Category == "dev", }) } return lf, nil } func parseRequirementsTxt(path string) (*model.Lockfile, error) { // requirements.txt has no integrity — parse name==version lines data, _ := os.ReadFile(path) lf := &model.Lockfile{Ecosystem: model.EcosystemPyPI, Path: path, ParsedAt: time.Now()} for _, line := range strings.Split(string(data), "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } // Handle: pkg==1.2.3, pkg>=1.2.3, pkg @ url parts := strings.Split(line, "==") if len(parts) == 2 { lf.Packages = append(lf.Packages, model.Package{ Name: strings.ToLower(strings.TrimSpace(parts[0])), Version: strings.TrimSpace(parts[1]), }) } } return lf, nil } 4.3 Composer — composer.lock
internal/parser/composer.go
package parser import ( "encoding/json" "os" "github.com/your-org/pre-ship-filter/internal/model" ) type ComposerParser struct{} func (ComposerParser) Ecosystem() model.Ecosystem { return model.EcosystemComposer } func (ComposerParser) LockfileNames() []string { return []string{"composer.lock"} } func (ComposerParser) Parse(path string) (*model.Lockfile, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } var raw struct { Packages []struct { Name string `json:"name"` Version string `json:"version"` VersionNormalized string `json:"version_normalized"` Source map[string]string `json:"source"` Dist map[string]string `json:"dist"` Require map[string]string `json:"require"` RequireDev map[string]string `json:"require-dev"` Scripts map[string]string `json:"scripts"` } `json:"packages"` PackagesDev []struct { Name string `json:"name"` Version string `json:"version"` Source map[string]string `json:"source"` Dist map[string]string `json:"dist"` Require map[string]string `json:"require"` Scripts map[string]string `json:"scripts"` } `json:"packages-dev"` } if err := json.Unmarshal(data, &raw); err != nil { return nil, err } lf := &model.Lockfile{Ecosystem: model.EcosystemComposer, Path: path, ParsedAt: time.Now()} addPkgs := func(pkgs []struct{ Name string; Version string; Source map[string]string; Dist map[string]string; Require map[string]string; Scripts map[string]string }, dev bool) { for _, pkg := range pkgs { p := model.Package{ Name: pkg.Name, Version: pkg.Version, Dev: dev, ResolvedURL: pkg.Dist["url"], Integrity: pkg.Dist["sha256"], Requires: pkg.Require, Scripts: pkg.Scripts, } lf.Packages = append(lf.Packages, p) } } addPkgs(raw.Packages, false) addPkgs(raw.PackagesDev, true) return lf, nil } 4.4 Parser Registry
internal/parser/registry.go
package parser import ( "path/filepath" "github.com/your-org/pre-ship-filter/internal/model" ) var parsers = []Parser{ &NPMParser{}, &PyPIParser{}, &ComposerParser{}, } func DetectAndParse(path string) (*model.Lockfile, error) { base := filepath.Base(path) for _, p := range parsers { for _, name := range p.LockfileNames() { if base == name { return p.Parse(path) } } } return nil, fmt.Errorf("no parser for lockfile: %s", base) } func FindLockfiles(root string) ([]string, error) { var files []string err := filepath.Walk(root,Source:https://dev.to/tamizuddin/building-a-pre-ship-supply-chain-security-filter-for-php-npm-and-pypi-39hd