Skip to content

Latest commit

Β 

History

History
643 lines (479 loc) Β· 30 KB

File metadata and controls

643 lines (479 loc) Β· 30 KB

System Architecture

This document breaks down how Bomber is designed, how data flows through the system, and why specific architectural decisions were made.

High Level Architecture

                         bomber <command> [path]
                                β”‚
                β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                β”‚         CLI Layer             β”‚
                β”‚  internal/cli/                β”‚
                β”‚  root.go β†’ scan/generate/     β”‚
                β”‚           vuln/check          β”‚
                β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                β”‚
         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
         β”‚              Scanner Engine                  β”‚
         β”‚  internal/scanner/scanner.go                 β”‚
         β”‚  Walks directories, skips vendor/node_modulesβ”‚
         β”‚  Dispatches to parser registry               β”‚
         β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                β”‚
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚          Parser Registry          β”‚
              β”‚  internal/parser/registry.go      β”‚
              β”‚  Detect() β†’ which parsers match   β”‚
              β”‚  Parse() β†’ build dependency graph β”‚
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                         β”‚           β”‚
         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€           β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
         β–Ό               β–Ό           β–Ό               β”‚
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”       β”‚
   β”‚ GoMod    β”‚   β”‚ Node     β”‚   β”‚ Python   β”‚       β”‚
   β”‚ Parser   β”‚   β”‚ Parser   β”‚   β”‚ Parser   β”‚       β”‚
   β”‚ go.mod   β”‚   β”‚ pkg.json β”‚   β”‚ pyprojectβ”‚       β”‚
   β”‚ go.sum   β”‚   β”‚ pnpm-lockβ”‚   β”‚ uv.lock  β”‚       β”‚
   β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜       β”‚
        β”‚               β”‚              β”‚              β”‚
        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜              β”‚
                        β–Ό                             β”‚
         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                 β”‚
         β”‚   Dependency Graphs      β”‚                 β”‚
         β”‚   pkg/types/types.go     β”‚                 β”‚
         β”‚   Nodes + Edges + Root   β”‚                 β”‚
         β”‚   Depth levels, cycles   β”‚                 β”‚
         β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                 β”‚
                    β”‚                                 β”‚
        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                    β”‚
        β–Ό           β–Ό            β–Ό                    β”‚
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”            β”‚
  β”‚ SPDX 2.3 β”‚ β”‚CycloneDX β”‚ β”‚ Vuln      β”‚            β”‚
  β”‚ Generatorβ”‚ β”‚1.5 Gen.  β”‚ β”‚ Matcher   β”‚            β”‚
  β”‚ spdx.go  β”‚ β”‚cyclonedx β”‚ β”‚ osv.go    β”‚            β”‚
  β”‚          β”‚ β”‚ .go      β”‚ β”‚ nvd.go    β”‚            β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ cvss.go   β”‚            β”‚
                            β”‚ cache.go  β”‚            β”‚
                            β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜            β”‚
                                  β”‚                  β”‚
                                  β–Ό                  β”‚
                         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”          β”‚
                         β”‚ Policy Engine  β”‚          β”‚
                         β”‚ engine.go      β”‚          β”‚
                         β”‚ max_severity   β”‚          β”‚
                         β”‚ max_depth      β”‚          β”‚
                         β”‚ max_age_days   β”‚          β”‚
                         β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜          β”‚
                                  β”‚                  β”‚
                                  β–Ό                  β”‚
                         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”          β”‚
                         β”‚ Report Layer   β”‚          β”‚
                         β”‚ terminal.go    β”‚β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                         β”‚ json.go        β”‚
                         β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Component Breakdown

CLI Layer (internal/cli/)

  • Purpose: Parse command-line arguments, wire up components, control output format
  • Responsibilities: Signal handling (SIGINT/SIGTERM via signal.NotifyContext), global flag management, dispatching to the correct subcommand
  • Interfaces: Consumes all internal packages, produces terminal/JSON output

Scanner Engine (internal/scanner/)

  • Purpose: Walk directories and discover ecosystems
  • Responsibilities: Recursive directory traversal, skipping known noise directories (node_modules, .git, vendor, __pycache__, .venv, etc.), dispatching detected directories to the parser registry
  • Interfaces: Takes a *parser.Registry, returns a *types.ScanResult

Parser Registry (internal/parser/)

  • Purpose: Manage ecosystem parsers and auto-detect which ones apply
  • Responsibilities: Store registered parsers, detect applicable parsers per directory, delegate parsing to the correct implementation
  • Interfaces: DependencyParser interface with Detect(dir) bool, Parse(dir) (*DependencyGraph, error), and Ecosystem() Ecosystem

SBOM Generators (internal/sbom/)

  • Purpose: Convert dependency graphs into spec-compliant SBOM documents
  • Responsibilities: SPDX 2.3 and CycloneDX 1.5 JSON serialization, namespace generation, relationship mapping
  • Interfaces: Each generator has a Generate([]*DependencyGraph) ([]byte, error) method

Vulnerability Matcher (internal/vuln/)

  • Purpose: Query external databases and match packages to known vulnerabilities
  • Responsibilities: OSV batch queries, NVD per-package queries, CVSS v3.1 score calculation, SQLite caching, rate limiting
  • Interfaces: Client interface with Query(ctx, packages) ([]VulnMatch, error) and Source() string

Policy Engine (internal/policy/)

  • Purpose: Evaluate vulnerability findings against configurable rules
  • Responsibilities: YAML policy loading, severity threshold checks, dependency depth limits, vulnerability age enforcement
  • Interfaces: Evaluate(policy, report, graphs) *CheckResult

Report Layer (internal/report/)

  • Purpose: Format output for humans or machines
  • Responsibilities: Colored terminal output with severity grouping, JSON report serialization
  • Interfaces: Each formatter writes to an io.Writer

Data Flow

Scan Command Flow

Step by step walkthrough of bomber scan ./project:

1. CLI parses args          β†’ internal/cli/scan.go (runScan)
   path = "./project", format from --format flag

2. Create parser registry   β†’ internal/parser/registry.go (RegisterAll)
   Registers GoModParser, NodeParser, PythonParser

3. Scanner walks directory  β†’ internal/scanner/scanner.go (Scan)
   discoverDirs("./project") recursively walks subdirectories
   Skips: node_modules, .git, vendor, __pycache__, .venv, dist, build, .tox, target

4. For each directory:
   registry.Detect(dir)     β†’ Each parser checks for its manifest file
   GoMod: os.Stat("go.mod")
   Node: os.Stat("package.json")
   Python: os.Stat("pyproject.toml")

5. Matched parser.Parse()  β†’ Ecosystem-specific parsing
   Returns *DependencyGraph with nodes (packages) and edges (dependencies)

6. Aggregate results       β†’ types.ScanResult
   TotalPkgs, DirectPkgs, Ecosystems

7. Output                  β†’ report/terminal.go or report/json.go

Vulnerability Scan Flow

Step by step walkthrough of bomber vuln ./project:

1. Scan phase (same as above, steps 1-6)

2. Collect all packages    β†’ internal/cli/vuln.go (queryVulns)
   Flatten all graphs into []types.Package via graph.AllPackages()

3. Initialize cache        β†’ internal/vuln/cache.go
   SQLite at ~/.bomber/cache.db with 24h TTL
   (skipped if --no-cache)

4. For each vulnerability client (OSV, optionally NVD):

   a. Check cache first    β†’ cache.Get(purl, source)
      Cache hit: add to matches, skip API call
      Cache miss: add to uncached list

   b. Query API            β†’ client.Query(ctx, uncachedPackages)
      OSV: POST /v1/querybatch with PURLs, batch size 1000
      NVD: GET per package with CPE, rate-limited

   c. Cache responses      β†’ cache.Put(purl, source, matches)
      Keyed by (purl, source) composite primary key

5. Deduplicate matches     β†’ deduplicateMatches()
   Cross-reference IDs and aliases (CVE, GHSA, GO-)
   Keep entry with higher CVSS score or fix version

6. Output                  β†’ Scan summary + vuln report
   Grouped by severity: CRITICAL β†’ HIGH β†’ MEDIUM β†’ LOW
   Sorted by CVSS score within each group

SBOM Generation Flow

Step by step walkthrough of bomber generate ./project --sbom-format spdx:

1. Scan phase (same steps 1-6)

2. Select generator        β†’ internal/cli/generate.go
   "spdx" β†’ NewSPDXGenerator()
   "cyclonedx" β†’ NewCycloneDXGenerator()

3. Generate document       β†’ generator.Generate(graphs)

   SPDX:
   - Build document namespace with SHA-256 hash
   - Create DESCRIBES relationship (document β†’ root)
   - For each node: create spdxPackage with PURL external ref
   - For each edge: create DEPENDS_ON relationship
   - Serialize to indented JSON

   CycloneDX:
   - Generate UUID for serial number
   - For each non-root node: create cdxComponent with PURL + bom-ref
   - For each edge set: create cdxDependency with dependsOn list
   - Serialize to indented JSON

4. Output                  β†’ stdout or --output file

Policy Check Flow

Step by step walkthrough of bomber check ./project --policy policy.yaml:

1. Load policy             β†’ internal/policy/rules.go (LoadPolicy)
   Parse YAML: max_severity, max_depth, max_age_days

2. Scan phase + Vuln phase (same as vuln flow)

3. Evaluate policy         β†’ internal/policy/engine.go (Evaluate)

   max_severity check:
   - Parse threshold (e.g., "medium" β†’ SeverityMedium, rank 2)
   - For each vuln match: if severity.Rank() > threshold.Rank() β†’ violation

   max_age_days check:
   - Compute cutoff date: now - maxAgeDays
   - For each vuln match: if published before cutoff β†’ violation
   - Skip vulns with zero published date

   max_depth check:
   - For each graph: compute MaxDepth()
   - If depth > maxDepth β†’ violation

4. Output + Exit code      β†’ report + os.Exit(1) if any violations

Design Patterns

Registry Pattern

What it is: A central registry that stores implementations of an interface and dispatches work based on runtime detection.

Where we use it: internal/parser/registry.go stores DependencyParser implementations. The scanner calls registry.Detect(dir) to find which parsers match a directory, then calls Parse() on each match.

Why we chose it: New ecosystems (Rust, Java, Ruby) can be added by implementing the DependencyParser interface and registering in RegisterAll(). Zero changes to the scanner, CLI, or any other package.

type DependencyParser interface {
    Detect(dir string) bool
    Parse(dir string) (*types.DependencyGraph, error)
    Ecosystem() types.Ecosystem
}

Trade-offs:

  • Pros: Open/closed principle. Adding Rust support means one new file, one line in RegisterAll()
  • Cons: All parsers must fit the same interface shape. If a parser needs fundamentally different inputs (like a network fetch), the interface doesn't accommodate that

Functional Options Pattern

What it is: Configuration via variadic function arguments that modify a struct's defaults.

Where we use it: Both vulnerability clients use this pattern:

client := vuln.NewOSVClient(WithOSVBaseURL(server.URL))
client := vuln.NewNVDClient(WithNVDBaseURL(url), WithNVDAPIKey(key))

Why we chose it: Tests need to point clients at httptest.Server URLs. Production code uses defaults from internal/config/config.go. The option functions make both cases clean without exposing internal fields or requiring a config struct.

Trade-offs:

  • Pros: Clean defaults, testable, backward-compatible when adding options
  • Cons: Slightly more boilerplate than a config struct

Strategy Pattern

What it is: Interchangeable implementations behind a common interface, selected at runtime.

Where we use it: The vuln.Client interface lets vuln.go treat OSV and NVD identically:

var clients []vuln.Client
clients = append(clients, osvClient)
if nvdKey != "" {
    clients = append(clients, vuln.NewNVDClient(vuln.WithNVDAPIKey(nvdKey)))
}
for _, client := range clients {
    matches, err := client.Query(ctx, uncached)
    ...
}

Why we chose it: OSV and NVD have completely different APIs (batch POST vs per-package GET), different authentication models (none vs API key), and different rate limits. The Client interface abstracts all of this away.

Layer Separation

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚    CLI Layer (internal/cli/)            β”‚
β”‚    - Parses arguments and flags         β”‚
β”‚    - Wires components together          β”‚
β”‚    - Controls output format             β”‚
β”‚    - Does NOT contain business logic    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                      ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚    Core Layer (internal/*)              β”‚
β”‚    - scanner, parser, graph, sbom,      β”‚
β”‚      vuln, policy                       β”‚
β”‚    - All business logic lives here      β”‚
β”‚    - Each package has a single concern  β”‚
β”‚    - Does NOT import cli or report      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                      ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚    Types Layer (pkg/types/)             β”‚
β”‚    - Shared data structures             β”‚
β”‚    - No business logic, no imports      β”‚
β”‚    - Package, Vulnerability, ScanResult β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

What Lives Where

CLI Layer:

  • Files: internal/cli/*.go
  • Imports: Everything in internal/ and pkg/types
  • Forbidden: Business logic. The CLI should only orchestrate calls to core packages.

Core Layer:

  • Files: internal/scanner/, internal/parser/, internal/graph/, internal/sbom/, internal/vuln/, internal/policy/
  • Imports: pkg/types and internal/config. Some core packages import other core packages (e.g., scanner imports parser and graph)
  • Forbidden: Importing internal/cli/ or internal/report/. Core logic should never know how it's being invoked.

Types Layer:

  • Files: pkg/types/types.go
  • Imports: Only standard library (time, strings)
  • Forbidden: Importing anything from internal/. Types are the foundation everything else builds on.

Data Models

Package

type Package struct {
    Name       string
    Version    string
    Ecosystem  Ecosystem
    PURL       string
    Checksums  []Checksum
    Direct     bool
    DepthLevel int
}

Fields explained:

  • Name: The package identifier within its ecosystem (e.g., github.com/spf13/cobra, express, requests)
  • Version: The resolved version from the lockfile (e.g., v1.10.2, 4.18.2, 2.31.0)
  • Ecosystem: Enum: EcosystemGo, EcosystemNode, EcosystemPython
  • PURL: The Package URL used as the universal identifier and map key
  • Checksums: Hash values from lockfiles (go.sum SHA-256, pnpm-lock integrity SHA-512)
  • Direct: Whether this package is listed in the manifest (not just the lockfile)
  • DepthLevel: BFS distance from the root package (0 = root, 1 = direct, 2+ = transitive)

DependencyGraph

type DependencyGraph struct {
    Root  Package
    Nodes map[string]Package    // keyed by PURL
    Edges map[string][]string   // parent PURL β†’ []child PURLs
}

The graph is an adjacency list. Nodes stores every package keyed by its PURL. Edges maps each parent to its list of children. The Root field identifies the project being scanned.

Vulnerability

type Vulnerability struct {
    ID            string
    Aliases       []string
    Severity      Severity
    Score         float64
    AffectedRange string
    FixVersion    string
    Summary       string
    Source        string
    Published     time.Time
}

A single vulnerability can have multiple identifiers. ID is the primary (e.g., GO-2023-2102), Aliases holds cross-references (e.g., ["CVE-2023-44487", "GHSA-qppj-fm5r-hxr3"]). Source tracks where the data came from ("osv" or "nvd").

Security Architecture

Threat Model

What we're protecting against:

  1. Known vulnerabilities in dependencies - The primary threat. A package in your dependency tree has a published CVE with a known fix version.
  2. Deep transitive dependency risk - Packages buried deep in the dependency tree that you never explicitly chose and may not know about.
  3. Stale vulnerability findings - Known vulnerabilities that go unpatched for extended periods, giving attackers time to develop exploits.

What we're NOT protecting against (out of scope):

  • Zero-day vulnerabilities - Bomber queries known vulnerability databases. If a vulnerability hasn't been published yet, Bomber won't find it.
  • Malicious packages with no CVE - A deliberately backdoored package that hasn't been reported to any vulnerability database won't be detected by Bomber.
  • Build pipeline compromise - Bomber analyzes source manifests, not build outputs. A compromised build system could inject dependencies not present in source.

External API Security

OSV API: No authentication required. Bomber sends only PURLs (package identifiers that are already public information). No sensitive data leaves the system.

NVD API: Optional API key sent via apiKey header. The key is read from the BOMBER_NVD_API_KEY environment variable, never from config files or command-line arguments (which would appear in process listings).

Cache: The SQLite cache at ~/.bomber/cache.db stores vulnerability responses. It contains only publicly available vulnerability data, not source code or credentials.

Storage Strategy

SQLite Cache

What we store:

  • Vulnerability matches keyed by (purl, source) composite primary key
  • Serialized as JSON blobs
  • Timestamp for TTL expiration

Why SQLite: SQLite is embedded (no server process), uses a single file, and modernc.org/sqlite is a pure-Go implementation (no CGo dependency). This means Bomber is a single statically-linked binary with no external dependencies.

Schema:

CREATE TABLE IF NOT EXISTS vuln_cache (
    purl TEXT NOT NULL,
    source TEXT NOT NULL,
    data BLOB NOT NULL,
    created_at INTEGER NOT NULL,
    PRIMARY KEY (purl, source)
)

The INSERT OR REPLACE strategy at internal/vuln/cache.go means re-scanning the same project updates stale entries without requiring explicit deletion.

Configuration

Environment Variables

BOMBER_NVD_API_KEY    # NVD API key for higher rate limits (200ms vs 1.7s)
BOMBER_INSTALL_DIR    # Override install location (default: ~/.bomber/bin)
BOMBER_VERSION        # Pin install script to a specific version

Configuration Strategy

Bomber uses a flags-only approach for configuration. There's no config file for the tool itself (unlike Portia's .portia.toml). This is deliberate: Bomber is designed for CI/CD pipelines where configuration comes from environment variables and command-line flags, not project-local config files.

The only file-based configuration is the policy file (policy.yaml), which defines security rules rather than tool behavior.

Performance Considerations

Bottlenecks

Where this system gets slow under load:

  1. NVD API queries - One request per package, rate-limited to 1.7s/request without an API key. A project with 200 dependencies takes ~340 seconds. With an API key: ~40 seconds. This is why OSV (batch queries, no rate limit) is the primary source.

  2. go mod graph execution - The Go parser shells out to go mod graph to build accurate edge relationships. This is an exec.Command call at internal/parser/gomod.go that can take seconds for large Go projects because it resolves the module graph.

Optimizations

What we did to make it faster:

  • OSV batch API: Instead of querying one package at a time, Bomber sends up to 1000 PURLs in a single POST request. This turns N HTTP round-trips into ceil(N/1000).

  • SQLite response caching: Repeated scans of the same project skip API calls entirely for packages already cached within the TTL window. The cache uses a composite key (purl, source) so OSV and NVD results are cached independently.

  • Directory skip list: The scanner skips 9 known noise directories (node_modules, .git, vendor, etc.) to avoid traversing millions of files in large monorepos.

Scalability

Vertical scaling: Bomber is single-threaded for API queries (both OSV and NVD). The OSV batch API makes this acceptable. NVD's rate limit is the binding constraint, not CPU or memory.

Horizontal scaling: For scanning hundreds of repositories, run Bomber as a parallel CI step per repository. Each instance maintains its own cache. There's no shared state between runs.

Design Decisions

Decision 1: OSV as Primary, NVD as Supplementary

What we chose: OSV is always queried. NVD is only queried if BOMBER_NVD_API_KEY is set.

Alternatives considered:

  • NVD only - Rejected because NVD doesn't support PURL (requires CPE translation, which is lossy) and has strict rate limits
  • Both always - Rejected because NVD without an API key is too slow for interactive use
  • OSV only - Viable but loses the enrichment NVD provides (more detailed CVSS data, broader coverage for some ecosystems)

Trade-offs: Users who want comprehensive coverage need an NVD API key. Users who want fast, good-enough results get OSV by default with no setup.

Decision 2: Pure-Go SQLite (modernc.org/sqlite)

What we chose: A pure-Go SQLite implementation with no CGo dependency.

Alternatives considered:

  • mattn/go-sqlite3 - Requires CGo and a C compiler. Cross-compilation becomes painful. Rejected for distribution simplicity.
  • BoltDB/BadgerDB - Key-value stores without SQL. Rejected because the (purl, source) composite key with TTL expiration maps naturally to SQL.
  • No cache - Rejected because vulnerability API calls are expensive (network latency + rate limits).

Trade-offs: modernc.org/sqlite is slower than the CGo version for heavy workloads, but Bomber's cache queries are trivial (point lookups and upserts). The distribution benefit (single static binary) far outweighs the performance difference.

Decision 3: Dependency Graph as Adjacency List

What we chose: map[string]Package for nodes and map[string][]string for edges, both keyed by PURL.

Alternatives considered:

  • Adjacency matrix - O(1) edge lookups but O(n^2) memory. Rejected for a system that commonly handles 50-500 packages.
  • Third-party graph library - Adds dependency for a relatively simple data structure. Rejected in favor of stdlib-only implementation.

Trade-offs: Adjacency lists are space-efficient and fast for BFS/DFS traversal (which is all Bomber needs). Edge existence checks are O(degree) instead of O(1), but no operation in Bomber requires frequent edge-existence queries.

Error Handling Strategy

Error Types

  1. Parse errors - A manifest file exists but can't be parsed (malformed TOML, invalid JSON). Handled by returning nil, error from Parse(). The scanner logs and continues to the next parser.

  2. Network errors - OSV or NVD API is unreachable. The vuln command continues with whatever results it has. Individual client errors don't abort the entire scan.

  3. Cache errors - SQLite can't be opened or queried. The scan proceeds without caching. Cache errors are silently ignored because they don't affect correctness.

  4. Policy violations - Not errors in the traditional sense. The policy engine returns a CheckResult with Passed: false, and the CLI exits with code 1.

Recovery Strategy

The general approach is best-effort degradation:

  • If go mod graph fails, the Go parser still returns packages from go.mod parsing. It just won't have accurate edge data.
  • If NVD returns an error for one package, Bomber logs it and continues with the rest. It doesn't abort.
  • If the cache can't be initialized, the scan proceeds without caching.

Extensibility

Adding a New Ecosystem Parser

Want to add Rust support? Here's where it goes:

  1. Create internal/parser/cargo.go implementing DependencyParser:

    • Detect(): check for Cargo.toml
    • Parse(): read Cargo.toml and Cargo.lock, build graph with pkg:cargo/ PURLs
    • Ecosystem(): return a new EcosystemRust constant
  2. Add EcosystemRust to the Ecosystem enum in pkg/types/types.go

  3. Register in internal/parser/registry.go:

    func RegisterAll(reg *Registry) {
        reg.Register(NewGoModParser())
        reg.Register(NewNodeParser())
        reg.Register(NewPythonParser())
        reg.Register(NewCargoParser())  // add this
    }

No changes needed to the scanner, SBOM generators, vulnerability matchers, policy engine, or CLI. The registry pattern handles dispatch.

Adding a New Vulnerability Source

Want to add GitHub Advisory Database support?

  1. Create internal/vuln/ghsa.go implementing the Client interface:

    • Query(): call the GitHub GraphQL API
    • Source(): return "ghsa"
  2. Add it to the client list in internal/cli/vuln.go:

    ghToken := os.Getenv("BOMBER_GITHUB_TOKEN")
    if ghToken != "" {
        clients = append(clients, vuln.NewGHSAClient(vuln.WithGHToken(ghToken)))
    }

The deduplication logic already handles aliases, so overlapping results between OSV and GHSA are merged automatically.

Comparison to Similar Systems

Syft (Anchore)

Syft is a mature SBOM generator that supports 20+ ecosystems and generates SPDX, CycloneDX, and its own JSON format. It scans container images, file systems, and archives.

How Bomber is different:

  • Bomber combines SBOM generation with vulnerability matching and policy enforcement in one binary. Syft focuses on SBOM generation and delegates vulnerability scanning to Grype.
  • Bomber is a focused learning project targeting three ecosystems deeply. Syft is a production tool covering breadth.

Grype (Anchore)

Grype is a vulnerability scanner that consumes SBOMs or scans images directly. It pairs with Syft.

How Bomber is different:

  • Bomber generates SBOMs and scans vulnerabilities in a single pipeline. Grype expects pre-generated SBOMs or raw file system access.
  • Bomber's policy engine is built-in. Grype relies on external tooling for policy enforcement.

Trivy (Aqua Security)

Trivy is a comprehensive security scanner that covers vulnerabilities, misconfigurations, secrets, and licenses.

How Bomber is different:

  • Bomber is a single-purpose tool: dependencies and vulnerabilities. Trivy is a multi-scanner covering container images, Kubernetes manifests, IaC files, and more.
  • Bomber is a ~2000 line Go project suitable for reading end-to-end. Trivy is a large production codebase.

Key Files Reference

Quick map of where to find things:

  • cmd/bomber/main.go - Entry point (3 lines)
  • pkg/types/types.go - All shared data structures
  • internal/config/config.go - Constants and API URLs
  • internal/cli/root.go - CLI setup, signal handling, flag definitions
  • internal/scanner/scanner.go - Directory walker and ecosystem dispatcher
  • internal/parser/parser.go - DependencyParser interface (4 lines)
  • internal/parser/registry.go - Parser registry and auto-detection
  • internal/parser/gomod.go - Go parser (go.mod, go.sum, go mod graph, BFS depth)
  • internal/parser/node.go - Node parser (package.json, pnpm-lock.yaml)
  • internal/parser/python.go - Python parser (pyproject.toml, uv.lock)
  • internal/graph/graph.go - Graph utilities (all/direct/transitive, cycles, merge)
  • internal/sbom/spdx.go - SPDX 2.3 JSON generator
  • internal/sbom/cyclonedx.go - CycloneDX 1.5 JSON generator
  • internal/vuln/client.go - Vulnerability client interface (4 lines)
  • internal/vuln/osv.go - OSV batch API client
  • internal/vuln/nvd.go - NVD REST API client with rate limiting
  • internal/vuln/cvss.go - CVSS v3.1 base score calculator
  • internal/vuln/cache.go - SQLite cache with TTL
  • internal/policy/rules.go - Policy YAML loader
  • internal/policy/engine.go - Policy evaluation engine
  • internal/report/terminal.go - Colored terminal output
  • internal/report/json.go - JSON report format

Next Steps

Now that you understand the architecture:

  1. Read 03-IMPLEMENTATION.md for code walkthrough
  2. Try modifying the Go parser to skip indirect dependencies and observe how the scan summary changes
  3. Try adding a new policy rule (e.g., blocked_packages: ["lodash"]) and follow the patterns in engine.go