Cybersecurity-Projects/PROJECTS/intermediate/secrets-scanner/learn/02-ARCHITECTURE.md

329 lines
15 KiB
Markdown

# System Architecture
This document breaks down how Portia is designed and why certain architectural decisions were made. We'll trace requests through the system and explain the tradeoffs.
## High Level Architecture
```
┌──────────────────────────────────────────────────────┐
│ CLI │
│ root.go, scan.go, git.go │
└───────────────────────┬──────────────────────────────┘
┌─────────┴─────────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Directory │ │ Git │
│ Source │ │ Source │
│ directory.go │ │ git.go │
└──────┬───────┘ └──────┬───────┘
│ │
└─────────┬─────────┘
▼ chan types.Chunk
┌─────────────────┐
│ Pipeline │
│ pipeline.go │
├─────────────────┤
│ │
│ ┌───────────┐ │
│ │ Worker 1 │ │
│ │ detector │ │
│ └───────────┘ │
│ ┌───────────┐ │
│ │ Worker 2 │ │
│ │ detector │ │
│ └───────────┘ │
│ ┌───────────┐ │
│ │ Worker N │ │
│ │ detector │ │
│ └───────────┘ │
│ │
└────────┬────────┘
▼ chan types.Finding
┌─────────────────┐
│ Collector │
│ dedup + merge │
└────────┬────────┘
┌────────┴────────┐
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ HIBP Check │ │ Reporter │
│ (optional) │ │ term/json/ │
│ client.go │ │ sarif │
└──────┬───────┘ └──────────────┘
│ ▲
└─────────────────┘
```
## Component Breakdown
### CLI Layer (`internal/cli/`)
**Purpose:** Parse command line arguments and orchestrate the scan workflow.
**Responsibilities:**
- Route `scan`, `git`, `init`, `pyproject`, `config` commands to their handlers
- Merge CLI flags with TOML config file values (CLI flags take precedence)
- Create the appropriate Source, run the Pipeline, optionally check HIBP, produce output
**Interfaces:** Uses Cobra for argument parsing. Each command is a `cobra.Command` with a `RunE` function. The `executeScan` function in `scan.go` is shared between `scan` and `git` commands.
### Config Loader (`internal/config/`)
**Purpose:** Load and merge configuration from `.portia.toml` files.
**Responsibilities:**
- Search for config files in three locations: current directory, `.portia/config.toml`, `~/.config/portia/config.toml`
- Fall back to `pyproject.toml` (`[tool.portia]` table) when no `.portia.toml` is found
- Parse TOML into a `Config` struct with sections for Rules, Scan, Output, HIBP, Allowlist
- Provide default templates for `portia init` and `portia pyproject`
**Interfaces:** `Load(path string) (*Config, error)` returns a config or error. Empty path triggers auto-discovery.
### Source Interface (`internal/source/`)
**Purpose:** Produce chunks of text from various inputs (directories, git history).
**Responsibilities:**
- Walk filesystem or git object tree
- Skip binary files, excluded paths, oversized files
- Split content into 50-line chunks with file path and line number metadata
- Send chunks into a channel for pipeline consumption
**Interfaces:**
```go
type Source interface {
Chunks(ctx context.Context, out chan<- types.Chunk) error
String() string
}
```
**Directory source** (`directory.go`):
- Uses `filepath.WalkDir` for filesystem traversal
- Skips `.git`, `node_modules`, `vendor`, `__pycache__`, `.venv`
- Checks file size against configurable max (default 1MB)
- Chunks files into 50-line segments using a buffered scanner
**Git source** (`git.go`):
- Uses go-git v5 for in-process git operations
- `scanHistory`: walks commit log backwards, extracts file content from each commit's tree
- `scanStaged`: reads git index entries for staged-only scanning
- Supports `--branch`, `--since`, `--depth` filters
### Rule Registry (`internal/rules/`)
**Purpose:** Store detection rules and provide fast keyword-based lookup.
**Responsibilities:**
- Store rules in a map keyed by rule ID
- Provide `MatchKeywords(content)` that returns only rules whose keywords appear in the content
- Support enabling/disabling rules
- Maintain global path and value allowlists
**Interfaces:** `Register(rule)`, `Get(id)`, `All()`, `MatchKeywords(content)`, `Disable(ids...)`, `Len()`
### Detection Engine (`internal/engine/`)
**Purpose:** Apply rules to chunks and produce findings.
**Detector** (`detector.go`):
- Takes a chunk, runs keyword pre-filter via registry
- For each matched rule, scans line by line with regex
- Extracts secret from capture group
- Validates entropy if the rule has an entropy threshold
- Runs through FilterFinding for false positive reduction
**Filter** (`filter.go`):
- `IsPlaceholder` - checks against GlobalValueAllowlist patterns
- `IsTemplated` - checks for `${...}`, `{{...}}`, `os.getenv()`, `process.env.`
- `IsStopword` - splits secret on `_-./` delimiters, checks parts against 700+ stopwords
- `IsAllowedPath` - checks file path against GlobalPathAllowlist
- `FilterFinding` - orchestrates all checks, returns true if finding is real
**Pipeline** (`pipeline.go`):
- Creates errgroup with source goroutine + N worker goroutines + collector goroutine
- Workers pull chunks from channel, run detector, push findings to findings channel
- Collector merges all findings, deduplicates by ruleID+filePath+secret+commitSHA
### HIBP Client (`internal/hibp/`)
**Purpose:** Check detected secrets against the Have I Been Pwned breach database.
**Responsibilities:**
- SHA-1 hash computation
- k-anonymity API queries (5-char prefix)
- LRU cache (10,000 entries) for repeated lookups
- Circuit breaker (5 failures = 60s cooldown)
**Interfaces:** `Check(ctx, secret) (Result, error)`
### Reporters (`internal/reporter/`)
**Purpose:** Format scan results for output.
**Terminal** (`terminal.go`): Colored output with severity-based colors (red for CRITICAL, yellow for MEDIUM), secret masking (show first/last few characters), SHA truncation for git commits, HIBP breach status.
**JSON** (`json.go`): Structured JSON with `findings` array and `summary` object. Secrets are masked in output.
**SARIF** (`sarif.go`): SARIF v2.1.0 compliant output with tool metadata, rule definitions, results with locations and properties.
**Interfaces:** `Reporter` interface with `Report(w io.Writer, result *types.ScanResult) error`. Factory function `New(format) Reporter` returns the appropriate implementation.
## Data Flow
### Tracing: `portia scan ./myproject`
Step-by-step walkthrough of what happens when you run a directory scan:
```
1. CLI parses arguments
root.go:init() → cobra.OnInitialize(initConfig)
scan.go:runScan() receives path="./myproject"
2. Config loading
root.go:initConfig() → config.Load(cfgFile)
Merges CLI flags with TOML config
Format defaults to "terminal", maxSize defaults to 1MB
3. Registry setup
scan.go:runScan() → rules.NewRegistry() + rules.RegisterBuiltins(reg)
Loads 150 rules into the registry map
Applies disabled rules from config: reg.Disable(cfg.Rules.Disable...)
4. Source creation
scan.go:runScan() → source.NewDirectory(path, maxSize, excludes)
Creates Directory struct with path, max file size, exclude patterns
5. Pipeline execution
scan.go:executeScan() → engine.NewPipeline(reg).Run(ctx, src)
5a. Source goroutine starts
Calls src.Chunks(ctx, chunks)
WalkDir traverses ./myproject
Skips .git, node_modules, vendor, binary extensions
Splits each file into 50-line chunks
Sends each chunk into the chunks channel
5b. Worker goroutines start (2-16 based on NumCPU)
Each pulls chunks from the channel
Calls detector.Detect(chunk):
- reg.MatchKeywords(chunk.Content) → only rules with matching keywords
- For each matched rule, scan each line with rule.Pattern regex
- Extract secret from capture group
- If rule has entropy threshold, compute Shannon entropy and compare
- Run FilterFinding: IsPlaceholder → IsTemplated → IsStopword → path allowlist
- If all checks pass, create Finding and send to findings channel
5c. Collector goroutine
Pulls findings from findings channel
Appends to allFindings slice (mutex-protected)
5d. Wait for all goroutines (errgroup.Wait)
Dedup findings by ruleID+filePath+secret+commitSHA
6. HIBP verification (if --hibp flag)
scan.go:checkHIBP(ctx, result)
For each finding, calls client.Check(ctx, finding.Secret)
Updates finding.HIBPStatus and finding.BreachCount
7. Reporter output
scan.go:executeScan() → reporter.New(format).Report(os.Stdout, result)
Terminal: colored table with severity, rule, file:line, masked secret
JSON: structured JSON to stdout
SARIF: SARIF v2.1.0 JSON to stdout
```
## Concurrency Model
The pipeline uses Go's errgroup pattern for structured concurrency:
```
errgroup
┌────────────────────────────────┐
│ │
Source goroutine │ ──chunks──▶ Worker 1 │
│ Worker 2 │
│ ... │
│ Worker N │
│ ──findings──▶ │
│ Collector │
│ │
└────────────────────────────────┘
```
**Why bounded workers?** CPU-bound regex matching doesn't benefit from unbounded parallelism. Too many goroutines competing for CPU time causes context switching overhead. The formula `min(max(NumCPU, 2), 16)` gives 2 workers on single-core machines and caps at 16 on large servers.
**Why errgroup?** It provides two things: (1) if any goroutine returns an error, the context is cancelled and all goroutines wind down cleanly, and (2) `g.Wait()` blocks until all goroutines complete, giving you a single point to check for errors.
**Channel sizing:** Channels are buffered at `workers * 4`. This allows the source to stay ahead of workers (avoiding blocking on sends) without unbounded memory growth. If workers are slow, the source will block once the buffer fills, providing natural backpressure.
**The detectWg dance:** Workers share a separate `sync.WaitGroup` so we know when all detection is done. The collector goroutine runs in the same errgroup but only closes after all workers finish. This prevents the collector from exiting early while findings are still being produced. See `pipeline.go:52-77`.
## Configuration Resolution
Configuration is resolved in this order (later overrides earlier):
```
1. Defaults (hardcoded)
Format: "terminal"
MaxSize: 1MB (1 << 20)
Workers: min(max(NumCPU, 2), 16)
HIBP: disabled
Verbose: false
NoColor: false
2. Config file (.portia.toml)
Searched in order:
.portia.toml (current directory)
.portia/config.toml
~/.config/portia/config.toml
First found is loaded. Later paths are not checked.
If none found, falls back to pyproject.toml ([tool.portia] table).
3. CLI flags
--format, --verbose, --no-color, --exclude, --max-size, --hibp, --config
These always win over config file values.
```
This merge logic is in `internal/cli/root.go:initConfig()`. The pattern is: check if the CLI flag was explicitly set (non-zero/non-empty), and only fall back to config file value if the flag wasn't set.
## Rule Matching Strategy
The detection pipeline is optimized for speed. Regex matching is expensive, so the goal is to avoid running regex against content that will never match.
```
Content chunk (50 lines of code)
┌───────────────────┐
│ Keyword Filter │ ← O(rules * keywords) string.Contains
│ ~95% eliminated │
└────────┬──────────┘
│ Only rules whose keywords appear in this chunk
┌───────────────────┐
│ Line-by-Line │ ← O(lines * matched_rules) regex
│ Regex Matching │
└────────┬──────────┘
│ Raw matches with capture groups
┌───────────────────┐
│ Secret Extract │ ← Extract from capture group
│ + Entropy Check │ Discard if below threshold
└────────┬──────────┘
│ Validated candidates
┌───────────────────┐
│ Filter Chain │ ← IsPlaceholder → IsTemplated
│ 5-layer check │ → IsStopword → Allowlists
└────────┬──────────┘
│ Real findings only
Finding
```
The keyword filter is the key performance optimization. If a 50-line chunk of HTML doesn't contain any strings like `password`, `secret`, `key`, `token`, `AKIA`, `ghp_`, `sk_live`, etc., then zero rules will match and zero regex patterns need to run against it. In practice, this eliminates the vast majority of chunks.