This document breaks down how Bomber is designed, how data flows through the system, and why specific architectural decisions were made.
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 β
ββββββββββββββββββ
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:
DependencyParserinterface withDetect(dir) bool,Parse(dir) (*DependencyGraph, error), andEcosystem() 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:
Clientinterface withQuery(ctx, packages) ([]VulnMatch, error)andSource() 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
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
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
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
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
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
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
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.
βββββββββββββββββββββββββββββββββββββββββββ
β 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 β
βββββββββββββββββββββββββββββββββββββββββββ
CLI Layer:
- Files:
internal/cli/*.go - Imports: Everything in
internal/andpkg/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/typesandinternal/config. Some core packages import other core packages (e.g.,scannerimportsparserandgraph) - Forbidden: Importing
internal/cli/orinternal/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.
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,EcosystemPythonPURL: The Package URL used as the universal identifier and map keyChecksums: 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)
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.
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").
What we're protecting against:
- Known vulnerabilities in dependencies - The primary threat. A package in your dependency tree has a published CVE with a known fix version.
- Deep transitive dependency risk - Packages buried deep in the dependency tree that you never explicitly chose and may not know about.
- 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.
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.
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.
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 versionBomber 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.
Where this system gets slow under load:
-
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.
-
go mod graphexecution - The Go parser shells out togo mod graphto build accurate edge relationships. This is anexec.Commandcall atinternal/parser/gomod.gothat can take seconds for large Go projects because it resolves the module graph.
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.
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.
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.
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.
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.
-
Parse errors - A manifest file exists but can't be parsed (malformed TOML, invalid JSON). Handled by returning
nil, errorfromParse(). The scanner logs and continues to the next parser. -
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.
-
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.
-
Policy violations - Not errors in the traditional sense. The policy engine returns a
CheckResultwithPassed: false, and the CLI exits with code 1.
The general approach is best-effort degradation:
- If
go mod graphfails, the Go parser still returns packages fromgo.modparsing. 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.
Want to add Rust support? Here's where it goes:
-
Create
internal/parser/cargo.goimplementingDependencyParser:Detect(): check forCargo.tomlParse(): readCargo.tomlandCargo.lock, build graph withpkg:cargo/PURLsEcosystem(): return a newEcosystemRustconstant
-
Add
EcosystemRustto theEcosystemenum inpkg/types/types.go -
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.
Want to add GitHub Advisory Database support?
-
Create
internal/vuln/ghsa.goimplementing theClientinterface:Query(): call the GitHub GraphQL APISource(): return"ghsa"
-
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.
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 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 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.
Quick map of where to find things:
cmd/bomber/main.go- Entry point (3 lines)pkg/types/types.go- All shared data structuresinternal/config/config.go- Constants and API URLsinternal/cli/root.go- CLI setup, signal handling, flag definitionsinternal/scanner/scanner.go- Directory walker and ecosystem dispatcherinternal/parser/parser.go- DependencyParser interface (4 lines)internal/parser/registry.go- Parser registry and auto-detectioninternal/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 generatorinternal/sbom/cyclonedx.go- CycloneDX 1.5 JSON generatorinternal/vuln/client.go- Vulnerability client interface (4 lines)internal/vuln/osv.go- OSV batch API clientinternal/vuln/nvd.go- NVD REST API client with rate limitinginternal/vuln/cvss.go- CVSS v3.1 base score calculatorinternal/vuln/cache.go- SQLite cache with TTLinternal/policy/rules.go- Policy YAML loaderinternal/policy/engine.go- Policy evaluation engineinternal/report/terminal.go- Colored terminal outputinternal/report/json.go- JSON report format
Now that you understand the architecture:
- Read 03-IMPLEMENTATION.md for code walkthrough
- Try modifying the Go parser to skip indirect dependencies and observe how the scan summary changes
- Try adding a new policy rule (e.g.,
blocked_packages: ["lodash"]) and follow the patterns inengine.go