# Implementation Guide This document walks through the actual code. We'll follow the data from CLI invocation through parsing, graph construction, SBOM generation, vulnerability matching, and policy evaluation. ## File Structure Walkthrough ``` sbom-generator-vulnerability-matcher/ ├── cmd/bomber/ │ └── main.go # Entry point (3 lines) ├── internal/ │ ├── cli/ │ │ ├── root.go # Root command, flags, signal handling │ │ ├── scan.go # Dependency scan command │ │ ├── generate.go # SBOM generation command │ │ ├── vuln.go # Vulnerability scan + deduplication │ │ └── check.go # Policy evaluation command │ ├── config/ │ │ └── config.go # Constants: URLs, timeouts, format versions │ ├── graph/ │ │ └── graph.go # Graph traversal and cycle detection │ ├── parser/ │ │ ├── parser.go # DependencyParser interface │ │ ├── registry.go # Parser registration and detection │ │ ├── gomod.go # Go: go.mod, go.sum, go mod graph │ │ ├── node.go # Node: package.json, pnpm-lock.yaml │ │ └── python.go # Python: pyproject.toml, uv.lock │ ├── policy/ │ │ ├── rules.go # Policy YAML structure and loading │ │ └── engine.go # Policy evaluation rules │ ├── report/ │ │ ├── terminal.go # Colored terminal report │ │ └── json.go # JSON report format │ ├── sbom/ │ │ ├── spdx.go # SPDX 2.3 JSON generator │ │ └── cyclonedx.go # CycloneDX 1.5 JSON generator │ ├── ui/ │ │ ├── banner.go # ASCII art banner │ │ ├── color.go # Color function wrappers │ │ ├── spinner.go # Terminal spinner for long operations │ │ └── symbol.go # Unicode symbols (check, cross, etc.) │ └── vuln/ │ ├── client.go # Client interface │ ├── osv.go # OSV batch API client │ ├── nvd.go # NVD REST API client │ ├── cvss.go # CVSS v3.1 calculator │ └── cache.go # SQLite response cache ├── pkg/types/ │ └── types.go # Core data structures └── testdata/ # Fixtures for all ecosystems ``` ## Building the Type System ### Core Types (`pkg/types/types.go`) Every data structure in Bomber flows through `pkg/types`. This is the foundation. The `Ecosystem` type is an iota enum with a `String()` method for human-readable output. The `Severity` type follows the same pattern but adds `Rank()` for numeric comparison and `ParseSeverity()` for converting strings from API responses: ```go type Severity int const ( SeverityNone Severity = iota SeverityLow SeverityMedium SeverityHigh SeverityCritical ) ``` The iota ordering is deliberate: `SeverityNone < SeverityLow < ... < SeverityCritical`. This means `Rank()` (which returns the int value) can be used for direct comparison in the policy engine. When the policy says `max_severity: medium` and a vulnerability is `HIGH`, the engine compares `SeverityHigh.Rank() > SeverityMedium.Rank()` which is `3 > 2` which is `true`, so it generates a violation. The `Package` struct carries everything Bomber knows about a dependency: ```go type Package struct { Name string Version string Ecosystem Ecosystem PURL string Checksums []Checksum Direct bool DepthLevel int } ``` `PURL` serves double duty: it's the universal identifier for external API queries and the map key for graph nodes. `Direct` distinguishes packages you explicitly declared from ones pulled in transitively. `DepthLevel` is computed via BFS after graph construction. The `DependencyGraph` is an adjacency list with a designated root: ```go type DependencyGraph struct { Root Package Nodes map[string]Package Edges map[string][]string } ``` Both maps are keyed by PURL strings. `NewDependencyGraph` initializes the maps and inserts the root node, ensuring the graph is never in an invalid state. ## Building the Parser System ### The Interface (`internal/parser/parser.go`) The entire parser interface is four lines: ```go type DependencyParser interface { Detect(dir string) bool Parse(dir string) (*types.DependencyGraph, error) Ecosystem() types.Ecosystem } ``` `Detect` checks if a directory contains this parser's manifest files. `Parse` reads those files and returns a fully constructed dependency graph. `Ecosystem` returns which ecosystem this parser handles. ### The Registry (`internal/parser/registry.go`) The registry stores parsers and provides detection: ```go func (r *Registry) Detect(dir string) []DependencyParser { var matched []DependencyParser for _, p := range r.parsers { if p.Detect(dir) { matched = append(matched, p) } } return matched } ``` A monorepo directory might match multiple parsers. If a directory has both `go.mod` and `package.json`, both the Go and Node parsers are returned. Each produces its own `DependencyGraph`. `RegisterAll` wires up all three parsers. This is the single place where adding a new ecosystem requires a change. ### Go Parser (`internal/parser/gomod.go`) The Go parser is the most complex because Go has three sources of dependency information: `go.mod` (manifest), `go.sum` (checksums), and `go mod graph` (edge relationships). **Phase 1: Parse go.mod** The parser reads `go.mod` line by line with `bufio.Scanner`. It tracks whether it's inside a `require (...)` block and classifies each dependency as direct or indirect based on the `// indirect` comment: ```go if strings.Contains(line, "// indirect") { indirectDeps[name] = version } else { directDeps[name] = version } ``` This is the same heuristic that `go mod` itself uses. Go doesn't have a separate manifest vs lockfile distinction: `go.mod` contains both direct and indirect dependencies, annotated with comments. **Phase 2: Parse go.sum for checksums** `parseGoSum` reads `go.sum` and extracts SHA-256 hashes. Each line in `go.sum` looks like: ``` github.com/spf13/cobra v1.10.2 h1:abc123base64... ``` The `h1:` prefix indicates a hash format. The parser base64-decodes the hash and converts it to hex: ```go raw := strings.TrimPrefix(hash, "h1:") decoded, err := base64.StdEncoding.DecodeString(raw) checksums[key] = append(checksums[key], types.Checksum{ Algorithm: "SHA-256", Value: hex.EncodeToString(decoded), }) ``` **Phase 3: Build edges with `go mod graph`** `go.mod` only tells you which packages exist. For accurate parent-child relationships, the parser shells out to `go mod graph`: ```go cmd := exec.Command("go", "mod", "graph") cmd.Dir = dir out, err := cmd.Output() ``` Each line of output is `parent@version child@version`. The parser converts these to PURLs and adds edges. If `go mod graph` fails (e.g., no Go toolchain available), the parser still returns the packages from phase 1, just without edge data. This is the best-effort degradation strategy. **Phase 4: Compute depth levels via BFS** After the graph is built, `computeDepthLevels` runs BFS from the root node: ```go func computeDepthLevels(graph *types.DependencyGraph) { depths := make(map[string]int) depths[graph.Root.PURL] = 0 queue := []string{graph.Root.PURL} for len(queue) > 0 { current := queue[0] queue = queue[1:] currentDepth := depths[current] for _, child := range graph.Edges[current] { if _, visited := depths[child]; !visited { depths[child] = currentDepth + 1 queue = append(queue, child) } } } ... } ``` BFS guarantees that each node gets the shortest path depth from root. A package reachable via both a depth-2 and depth-4 path gets depth 2. ### Node Parser (`internal/parser/node.go`) The Node parser reads `package.json` for project metadata and direct dependency declarations, then `pnpm-lock.yaml` for resolved versions and transitive dependencies. **package.json parsing** uses `encoding/json` to unmarshal into a struct: ```go type packageJSON struct { Name string `json:"name"` Version string `json:"version"` Dependencies map[string]string `json:"dependencies"` DevDependencies map[string]string `json:"devDependencies"` } ``` Both `dependencies` and `devDependencies` are treated as direct dependencies. This is intentional: dev dependencies can also have vulnerabilities, and in a development environment, they're on the attack surface. **pnpm-lock.yaml parsing** is more involved. The lock file has two key sections: `packages` (resolved package metadata) and `snapshots` (dependency relationships). The `splitPnpmKey` function handles pnpm's key format, which uses `@` as both a scoped package prefix and a version separator: ```go func splitPnpmKey(key string) (string, string) { atIdx := strings.LastIndex(key, "@") if atIdx <= 0 { return "", "" } return key[:atIdx], key[atIdx+1:] } ``` Using `LastIndex` handles scoped packages correctly: `@angular/core@17.0.0` splits at the last `@` into name `@angular/core` and version `17.0.0`. **PURL encoding for scoped packages:** npm scoped packages start with `@`, but PURL's `@` denotes a version separator. The `encodePURLName` function percent-encodes the leading `@`: ```go func encodePURLName(name string) string { if strings.HasPrefix(name, "@") { return strings.Replace(name, "@", "%40", 1) } return name } ``` **Fallback:** If `pnpm-lock.yaml` doesn't exist, the parser falls back to `parseFromPackageJSON`, which uses `package.json` dependencies directly. Version constraints (like `^4.18.2`) are cleaned using a regex that extracts the semver portion: ```go var nodeSemverRe = regexp.MustCompile(`\d+\.\d+\.\d+`) func cleanNodeVersion(constraint string) string { match := nodeSemverRe.FindString(constraint) if match != "" { return match } return strings.TrimLeft(constraint, "^~>=< ") } ``` ### Python Parser (`internal/parser/python.go`) The Python parser reads `pyproject.toml` for project metadata and `uv.lock` for resolved versions. **pyproject.toml parsing** uses `go-toml/v2`: ```go type pyprojectTOML struct { Project struct { Name string `toml:"name"` Version string `toml:"version"` Dependencies []string `toml:"dependencies"` } `toml:"project"` DependencyGroups map[string][]string `toml:"dependency-groups"` } ``` Python dependency specifiers include version constraints inline: `requests>=2.31.0`. The `extractPyPkgName` function strips everything after the first version operator: ```go var pyVersionRe = regexp.MustCompile(`[><=!~;]`) func extractPyPkgName(spec string) string { loc := pyVersionRe.FindStringIndex(spec) if loc != nil { return strings.TrimSpace(spec[:loc[0]]) } return strings.TrimSpace(spec) } ``` **uv.lock parsing** handles virtual packages (the project root itself, which has `source.virtual` set) by skipping them: ```go if pkg.Source.Virtual != "" { continue } ``` The parser builds a `purlMap` (name -> PURL) during the first pass, then uses it to resolve edges in a second pass based on each package's `dependencies` list. **Name normalization:** PyPI treats package names case-insensitively and normalizes hyphens/underscores. The parser lowercases all names before PURL construction and before matching against the direct dependency set. ## Building the Graph Package ### Graph Utilities (`internal/graph/graph.go`) The graph package provides pure functions that operate on `*types.DependencyGraph`. It doesn't own any state. **AllPackages, DirectPackages, TransitivePackages** are simple filters over `g.Nodes`. `DirectPackages` excludes the root node itself (you don't want "my-project" counted as a dependency of itself). **DetectCycles** uses DFS with a "current stack" tracking pattern: ```go func DetectCycles(g *types.DependencyGraph) [][]string { var cycles [][]string visited := make(map[string]bool) inStack := make(map[string]bool) var dfs func(purl string, path []string) dfs = func(purl string, path []string) { if inStack[purl] { for i, p := range path { if p == purl { cycle := make([]string, len(path)-i) copy(cycle, path[i:]) cycles = append(cycles, cycle) return } } return } if visited[purl] { return } visited[purl] = true inStack[purl] = true path = append(path, purl) for _, child := range g.Edges[purl] { dfs(child, path) } inStack[purl] = false } ... } ``` The `visited` set prevents re-exploring completed subtrees. The `inStack` set tracks the current DFS path. If we encounter a node that's already in the current stack, we've found a cycle. The `path` slice records the actual nodes in the cycle for reporting. **MergeGraphs** combines multiple graphs under a synthetic root. This is used when scanning a monorepo where each ecosystem gets its own graph. The merged graph has a root at `pkg:merged/root` with edges to each original root. ## Building the Scanner ### Scanner Engine (`internal/scanner/scanner.go`) The scanner ties parsers to directory discovery: ```go func (s *Scanner) Scan(dir string) (*types.ScanResult, error) { result := &types.ScanResult{} ecosystemSet := make(map[types.Ecosystem]bool) dirs := discoverDirs(dir) for _, d := range dirs { matched := s.registry.Detect(d) for _, p := range matched { g, err := p.Parse(d) if err != nil { continue } result.Graphs = append(result.Graphs, g) ecosystemSet[p.Ecosystem()] = true } } ... } ``` `discoverDirs` recursively walks the directory tree, skipping known noise directories. It returns a flat list of directories to check. The scanner then runs detection and parsing on each one. Parse errors are silently ignored (`continue`). This is intentional: a monorepo might have directories with partial or broken manifest files. The scanner should process what it can. The skip list is hard-coded: ```go var skipDirs = map[string]bool{ "node_modules": true, ".git": true, "vendor": true, "__pycache__": true, ".venv": true, "dist": true, "build": true, ".tox": true, "target": true, } ``` These are well-known directories that either contain resolved dependencies (which would cause double-counting) or build artifacts (which aren't source). ## Building the SBOM Generators ### SPDX 2.3 Generator (`internal/sbom/spdx.go`) The SPDX generator maps dependency graphs to the SPDX 2.3 JSON format. **Document namespace generation** creates a globally unique identifier: ```go nsHash := fmt.Sprintf("%x", sha256.Sum256([]byte(docName+now))) namespace := fmt.Sprintf("https://spdx.org/spdxdocs/%s-%s", docName, nsHash[:16]) ``` The namespace combines the project name and timestamp, hashed with SHA-256 and truncated to 16 hex characters. This ensures uniqueness without relying on random UUIDs (SPDX convention). **SPDX ID sanitization** is required because SPDX IDs must match `[a-zA-Z0-9.-]+`. PURLs contain characters like `/`, `@`, `:`, and `%` that aren't allowed: ```go func sanitizeSPDXID(purl string) string { r := strings.NewReplacer( "/", "-", "@", "-", ":", "-", ".", "-", "%", "-", ) return "SPDXRef-" + r.Replace(purl) } ``` **Relationships** use two SPDX relationship types: - `DESCRIBES`: Links the document to the root package - `DEPENDS_ON`: Links parent packages to child packages Every edge in every graph becomes a `DEPENDS_ON` relationship. This is the standard way to represent dependency trees in SPDX. ### CycloneDX 1.5 Generator (`internal/sbom/cyclonedx.go`) CycloneDX uses a different model: components (the packages) and dependencies (the relationships) are separate top-level arrays. **Key difference from SPDX:** CycloneDX uses `bom-ref` as the linking identifier between components and dependencies. Bomber uses the PURL directly as the `bom-ref`: ```go comp := cdxComponent{ Type: "library", Name: pkg.Name, Version: pkg.Version, PURL: pkg.PURL, BOMRef: pkg.PURL, } ``` The root package is excluded from the components list (`if pkg.PURL == graph.Root.PURL { continue }`). CycloneDX represents the root project in the metadata section, not as a component. **UUID generation** uses `google/uuid` for the serial number: ```go SerialNum: fmt.Sprintf("urn:uuid:%s", uuid.New().String()), ``` Each generated SBOM gets a unique identifier, which is useful for tracking document revisions and provenance. ## Building the Vulnerability Matcher ### Client Interface (`internal/vuln/client.go`) ```go type Client interface { Query(ctx context.Context, packages []types.Package) ([]types.VulnMatch, error) Source() string } ``` `Source()` returns a string identifier (`"osv"` or `"nvd"`) used for cache keying and deduplication attribution. ### OSV Client (`internal/vuln/osv.go`) The OSV client implements batch queries. It chunks packages into groups of 1000 (the API limit) and sends POST requests: ```go func (c *OSVClient) Query(ctx context.Context, packages []types.Package) ([]types.VulnMatch, error) { var allMatches []types.VulnMatch for i := 0; i < len(packages); i += config.OSVBatchSize { if err := ctx.Err(); err != nil { return allMatches, err } end := i + config.OSVBatchSize if end > len(packages) { end = len(packages) } batch := packages[i:end] matches, err := c.queryBatch(ctx, batch) ... } return allMatches, nil } ``` The context check at each iteration enables graceful cancellation: if the user hits Ctrl+C, the signal handler cancels the context, and the next batch iteration returns early. **Response parsing** correlates results with input packages by index. The OSV batch API returns results in the same order as the query: ```go for i, result := range batchResp.Results { if i >= len(packages) { break } pkg := packages[i] for _, v := range result.Vulns { ... } } ``` **Severity extraction** follows a priority chain: first check `database_specific.severity` (which some ecosystem databases provide as a simple label), then fall back to parsing the CVSS v3 vector from the `severity` array: ```go func parseSeverityFromOSV(v osvVuln) types.Severity { if v.DBSpec.Severity != "" { return types.ParseSeverity(v.DBSpec.Severity) } for _, s := range v.Severity { if s.Type == "CVSS_V3" { score := parseCVSSScore(s.Score) return scoreToSeverity(score) } } return types.SeverityNone } ``` ### NVD Client (`internal/vuln/nvd.go`) The NVD client queries one package at a time because the NVD API doesn't support batching. **CPE construction** converts a PURL-style identifier to CPE format: ```go func buildCPEString(pkg types.Package) string { product := pkg.Name if idx := strings.LastIndex(product, "/"); idx >= 0 { product = product[idx+1:] } product = strings.ToLower(product) version := strings.TrimPrefix(pkg.Version, "v") if version == "" { version = "*" } return fmt.Sprintf("cpe:2.3:a:*:%s:%s:*:*:*:*:*:*:*", product, version) } ``` For Go modules like `golang.org/x/net`, this extracts `net` as the product name. The vendor is wildcarded because CPE vendor names are inconsistent across NVD records. This is a best-effort approach; some packages won't match their NVD entries. **Rate limiting** uses a mutex-protected timer: ```go func (c *NVDClient) rateLimit(ctx context.Context) { c.mu.Lock() defer c.mu.Unlock() elapsed := time.Since(c.lastReq) if elapsed < c.rateDelay { wait := c.rateDelay - elapsed timer := time.NewTimer(wait) defer timer.Stop() select { case <-ctx.Done(): return case <-timer.C: } } c.lastReq = time.Now() } ``` The `select` with `ctx.Done()` ensures the rate limiter respects cancellation. Without it, Ctrl+C during NVD scanning would block until the timer expires. The rate delay is configured differently based on authentication: 200ms with an API key, 1700ms without. This matches NVD's documented limits. ### CVSS v3.1 Calculator (`internal/vuln/cvss.go`) The calculator implements the CVSS v3.1 specification from FIRST.org. **Vector parsing** extracts the 8 metric values from a CVSS vector string and converts them to numeric weights: ```go func parseCVSSVector(vector string) *cvssMetrics { if !strings.HasPrefix(vector, "CVSS:3") { return nil } parts := strings.Split(vector, "/") if len(parts) < 9 { return nil } vals := make(map[string]string, 8) for _, part := range parts[1:] { kv := strings.SplitN(part, ":", 2) if len(kv) == 2 { vals[kv[0]] = kv[1] } } ... } ``` The function returns `nil` for invalid vectors (wrong prefix, missing metrics, unknown values). This means unparseable vectors produce a score of 0.0. **Privileges Required** weights depend on Scope. If the scope is changed, privilege requirements are less effective at reducing risk: ```go prMap := prWeightsUnchanged if s == scopeChanged { prMap = prWeightsChanged } ``` For example, `PR:L` with `S:U` maps to weight 0.62, but `PR:L` with `S:C` maps to 0.68. This reflects the CVSS specification's view that scope changes make privilege requirements less meaningful. **Score calculation** follows the spec exactly: ```go iss := 1 - ((1 - metrics.C) * (1 - metrics.I) * (1 - metrics.A)) var impact float64 if metrics.S == scopeUnchanged { impact = 6.42 * iss } else { impact = 7.52*(iss-0.029) - 3.25*math.Pow(iss-0.02, 15) } if impact <= 0 { return 0 } exploitability := 8.22 * metrics.AV * metrics.AC * metrics.PR * metrics.UI ``` If impact is zero or negative (all CIA impacts are None), the score is 0 regardless of exploitability. You can't have a vulnerability if nothing is affected. **CVSS round-up** is a spec-defined operation that rounds to one decimal place, always rounding up: ```go func cvssRoundUp(val float64) float64 { shifted := math.Round(val*100000) / 100000 return math.Ceil(shifted*10) / 10 } ``` The intermediate `Round` at 5 decimal places handles floating-point precision issues before the final `Ceil`. ### SQLite Cache (`internal/vuln/cache.go`) The cache stores vulnerability results to avoid redundant API calls: ```go func NewCache(dbPath string, ttl time.Duration) (*Cache, error) { dir := filepath.Dir(dbPath) if err := os.MkdirAll(dir, 0o755); err != nil { return nil, fmt.Errorf("create cache dir: %w", err) } db, err := sql.Open("sqlite", dbPath) ... } ``` `MkdirAll` creates `~/.bomber/` if it doesn't exist. The SQLite driver is `modernc.org/sqlite`, a pure-Go implementation that requires no CGo. **Cache reads** check TTL before returning results: ```go func (c *Cache) Get(purl, source string) ([]types.VulnMatch, bool, error) { ... created := time.UnixMilli(createdAt) if time.Since(created) > c.ttl { return nil, false, nil } ... } ``` Expired entries aren't deleted on read. They're left in place and overwritten on the next write (`INSERT OR REPLACE`). This avoids the need for a separate cleanup goroutine. ## Building the Policy Engine ### Policy Loading (`internal/policy/rules.go`) ```go type Policy struct { MaxSeverity string `yaml:"max_severity"` MaxAgeDays int `yaml:"max_age_days"` MaxDepth int `yaml:"max_depth"` } ``` The policy file is YAML. An empty field means the rule is disabled. A minimal policy might be just `max_severity: medium`. ### Policy Evaluation (`internal/policy/engine.go`) The engine checks three rule types sequentially: **Severity check** compares each vulnerability's severity rank against the threshold: ```go if p.MaxSeverity != "" { threshold := types.ParseSeverity(p.MaxSeverity) for _, m := range report.Matches { if m.Vulnerability.Severity.Rank() > threshold.Rank() { ... result.Passed = false } } } ``` Setting `max_severity: medium` allows None, Low, and Medium but fails on High or Critical. **Age check** computes a cutoff date and compares against each vulnerability's published date: ```go if p.MaxAgeDays > 0 { cutoff := time.Now().AddDate(0, 0, -p.MaxAgeDays) for _, m := range report.Matches { if !m.Vulnerability.Published.IsZero() && m.Vulnerability.Published.Before(cutoff) { ... } } } ``` The `IsZero()` check handles vulnerabilities where the published date isn't available (some OSV entries lack this field). Unknown dates don't trigger the rule. **Depth check** operates on the graph structure, not individual vulnerabilities: ```go if p.MaxDepth > 0 { for _, g := range graphs { depth := graph.MaxDepth(g) if depth > p.MaxDepth { ... } } } ``` This rule is ecosystem-wide: if any dependency in any graph exceeds the depth limit, the check fails. ## Building the Vulnerability Orchestrator ### Cache-First Query Strategy (`internal/cli/vuln.go`) The `queryVulns` function in `vuln.go` orchestrates the full vulnerability scanning pipeline. It iterates each client, checks cache first, queries uncached packages, and stores results: ```go for _, client := range clients { var uncached []types.Package for _, pkg := range allPkgs { if cache != nil { cached, ok, err := cache.Get(pkg.PURL, client.Source()) if err == nil && ok { vulnReport.Matches = append(vulnReport.Matches, cached...) continue } } uncached = append(uncached, pkg) } if len(uncached) == 0 { continue } matches, err := client.Query(ctx, uncached) ... } ``` ### Deduplication When both OSV and NVD report the same vulnerability, `deduplicateMatches` merges them: ```go func deduplicateMatches(matches []types.VulnMatch) []types.VulnMatch { seen := make(map[string]int) var deduped []types.VulnMatch for _, m := range matches { ids := make([]string, 0, 1+len(m.Vulnerability.Aliases)) ids = append(ids, m.Vulnerability.ID) ids = append(ids, m.Vulnerability.Aliases...) existingIdx := -1 for _, id := range ids { if idx, ok := seen[id]; ok { existingIdx = idx break } } if existingIdx >= 0 { existing := deduped[existingIdx] if m.Vulnerability.Score > existing.Vulnerability.Score || (m.Vulnerability.FixVersion != "" && existing.Vulnerability.FixVersion == "") { deduped[existingIdx] = m } continue } ... } } ``` The `seen` map tracks every ID and alias for every vulnerability. When a new match arrives, all its IDs and aliases are checked against the map. If there's overlap, the better entry wins: higher CVSS score takes priority, and entries with fix versions are preferred over those without. ## Building the Report Layer ### Terminal Output (`internal/report/terminal.go`) **Scan summary** is concise: total packages, direct vs transitive breakdown, detected ecosystems, and cycle warnings: ```go func PrintScanSummary(w io.Writer, result *types.ScanResult) { fmt.Fprintf(w, " %s Scanned %d packages (%d direct", ui.Check, result.TotalPkgs, result.DirectPkgs) transitive := result.TotalPkgs - result.DirectPkgs if transitive > 0 { fmt.Fprintf(w, ", %d transitive", transitive) } ... } ``` **Vulnerability report** groups by severity (critical first), sorts by CVSS score within each group, and color-codes headers: ```go switch sev { case types.SeverityCritical: fmt.Fprintf(w, " %s\n", ui.Red(header)) case types.SeverityHigh: fmt.Fprintf(w, " %s\n", ui.Yellow(header)) case types.SeverityMedium: fmt.Fprintf(w, " %s\n", ui.Cyan(header)) default: fmt.Fprintf(w, " %s\n", ui.Dim(header)) } ``` Each vulnerability shows the affected PURL, vulnerability ID, truncated summary (60 char max), CVSS score, and fix version if available. ### JSON Output (`internal/report/json.go`) The JSON reporter wraps all three result types into a single envelope: ```go type JSONReport struct { Scan *types.ScanResult `json:"scan,omitempty"` Vulns *types.VulnReport `json:"vulnerabilities,omitempty"` Policy *types.CheckResult `json:"policy,omitempty"` } ``` Fields are `omitempty` so that `bomber scan --format json` only includes the scan section, while `bomber check --format json` includes all three. ## Testing Strategy ### Unit Tests Each package has focused unit tests that verify behavior without external dependencies: - **Parser tests** use `testdata/` fixtures: real `go.mod`, `package.json`, `pyproject.toml`, and lockfiles. Tests verify correct package counts, version extraction, direct/transitive classification, and checksum parsing. - **Graph tests** construct graphs programmatically and verify traversal, cycle detection, and merging. - **CVSS tests** verify known CVE vectors against expected scores (e.g., CVE-2023-44487 = 7.5, Log4Shell = 10.0). - **Cache tests** use `t.TempDir()` for isolated SQLite databases and verify put/get, expiry, miss, and overwrite behavior. ### Integration Tests `internal/scanner/integration_test.go` tests the full pipeline: scan testdata directories, build graphs, verify no cycles, generate SPDX and CycloneDX output, validate JSON. ### Vulnerability Client Tests OSV and NVD tests use `net/http/httptest` servers that return fixture responses. This avoids flaky tests from real API calls while still testing the full HTTP request/response cycle, including URL construction, header setting, and response parsing. ### Running Tests ```bash just test # go test -race ./... just test-v # with verbose output just cover # with coverage summary just cover-html # generate HTML coverage report ``` ## Dependencies ### Why Each Dependency - **cobra** (v1.10.2): CLI framework. Bomber has four subcommands with persistent flags, custom help rendering, and signal handling. Cobra handles all of this. - **fatih/color** (v1.19.0): Terminal color output. Used via wrapper functions at `internal/ui/color.go`. Respects `NO_COLOR` environment variable and `--no-color` flag. - **go-toml/v2** (v2.3.0): TOML parser for `pyproject.toml` and `uv.lock`. Uses struct tags for clean deserialization. - **yaml.v3** (v3.0.1): YAML parser for `pnpm-lock.yaml` and `policy.yaml`. - **modernc.org/sqlite** (v1.48.1): Pure-Go SQLite implementation. No CGo, no C compiler needed, single static binary output. - **google/uuid** (v1.6.0): UUID generation for CycloneDX serial numbers. - **testify** (v1.11.1): Test assertions and requirements. Used throughout the test suite for `assert.Equal`, `require.NoError`, etc. ## Next Steps You've seen how the code works. Now: 1. **Try the challenges** - [04-CHALLENGES.md](./04-CHALLENGES.md) has extension ideas from easy to expert 2. **Modify the code** - Change the Go parser to use `go list -m -json all` instead of `go mod graph` and compare the output 3. **Read related projects** - The secrets-scanner project uses similar patterns (Cobra CLI, registry, testdata fixtures) in a different security domain