15 KiB
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,configcommands 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.tomlis found - Parse TOML into a
Configstruct with sections for Rules, Scan, Output, HIBP, Allowlist - Provide default templates for
portia initandportia 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:
type Source interface {
Chunks(ctx context.Context, out chan<- types.Chunk) error
String() string
}
Directory source (directory.go):
- Uses
filepath.WalkDirfor 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 treescanStaged: reads git index entries for staged-only scanning- Supports
--branch,--since,--depthfilters
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 patternsIsTemplated- checks for${...},{{...}},os.getenv(),process.env.IsStopword- splits secret on_-./delimiters, checks parts against 700+ stopwordsIsAllowedPath- checks file path against GlobalPathAllowlistFilterFinding- 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.