17 KiB
System Architecture
This document breaks down how sentinel is designed and why certain architectural decisions were made.
High Level Architecture
┌────────────────────────────────────────────────────────────┐
│ CLI Layer (cobra) │
│ cmd/sentinel/main.go → internal/cli/root.go │
│ Subcommands: scan, baseline save, baseline diff │
│ Flags: --json, --min-severity, --root, --ignore-file │
└──────────────────────────┬─────────────────────────────────┘
│
┌────────────┼────────────────┐
▼ ▼ ▼
┌──────────────────┐ ┌───────────┐ ┌──────────────────┐
│ Scanner Registry │ │ Config │ │ Baseline │
│ scanner.RunAll() │ │ Ignore │ │ Save/Load/Diff │
│ 17 modules │ │ List │ │ JSON snapshots │
│ parallel via │ │ Filter │ │ │
│ errgroup │ │ │ │ │
└────────┬─────────┘ └─────┬─────┘ └─────────┬─────────┘
│ │ │
▼ ▼ ▼
┌──────────────────────────────────────────────────────────┐
│ []types.Finding │
│ Scanner, Severity, Title, Path, Evidence, MITRE │
└──────────────────────────┬───────────────────────────────┘
│
┌────────────┼────────────────┐
▼ ▼ ▼
┌──────────────────┐ ┌──────────┐ ┌──────────────────┐
│ Terminal Report │ │ JSON │ │ UI (banner, │
│ Color-coded │ │ Report │ │ spinner, colors)│
│ severity groups │ │ stdout │ │ │
└──────────────────┘ └──────────┘ └──────────────────┘
Component Breakdown
CLI Layer (internal/cli/)
- Purpose: Parse flags, dispatch to scan or baseline workflows, format output
- Responsibilities: Flag validation, hostname detection, severity filtering, output mode selection
- Interfaces: Calls scanner.RunAll(), config.LoadIgnoreFile(), baseline.Save/Load/Diff()
Scanner Registry (internal/scanner/scanner.go)
- Purpose: Collect all scanner modules and run them in parallel
- Responsibilities: Registration at init time, goroutine coordination, result merging
- Interfaces: Exposes Register(), All(), and RunAll()
Pattern Engine (internal/scanner/patterns.go)
- Purpose: Centralized regex matching for suspicious content across all scanners
- Responsibilities: Compiled pattern definitions, severity ranking, single-function match API
- Interfaces: MatchLine() returns (matched, severity, label)
Config (internal/config/)
- Purpose: Load and apply ignore rules to suppress known-good findings
- Responsibilities: YAML-like file parsing, finding filtering by path/scanner/title
Baseline (internal/baseline/)
- Purpose: Persist scan results and compute diffs between snapshots
- Responsibilities: JSON serialization, composite-key deduplication
Report (internal/report/)
- Purpose: Render findings as colored terminal output or structured JSON
- Responsibilities: Severity sorting, color mapping, evidence truncation, summary statistics
UI (internal/ui/)
- Purpose: Terminal presentation (banner, spinner, colors, symbols)
- Responsibilities: ANSI color functions, braille spinner animation, cursor management
Data Flow
Scan Command Flow
Step by step walkthrough of what happens when the user runs sentinel scan:
1. main.go imports internal/scanner (blank import)
All 17 scanner init() functions call Register()
Registry now holds 17 Scanner implementations
2. cobra dispatches to runScan() in cli/scan.go
Parses --min-severity, --root, --ignore-file flags
Starts spinner if not in JSON mode
3. scanner.RunAll(root) launches 17 goroutines via errgroup
Each goroutine calls scanner.Scan(root)
Each scanner reads files under root, applies MatchLine()
Findings collected under mutex into shared slice
4. config.LoadIgnoreFile() → ignoreList.Filter(findings)
Removes any findings matching ignore rules
5. filterBySeverity(findings, minSev)
Drops findings below the requested threshold
6. types.Tally(filtered) counts per-severity totals
7. report.PrintTerminal() or report.PrintJSON()
Terminal: sort by severity descending, color-code, print summary
JSON: encode ScanResult to stdout
Baseline Diff Flow
1. baseline save: RunAll() → baseline.Save()
Serializes findings + hostname + version to JSON file
2. baseline diff: baseline.Load() → RunAll() → baseline.Diff()
Loads saved snapshot
Runs fresh scan
Builds map of known findings by scanner|path|title key
Returns only findings not present in baseline
3. Apply ignore-list filter and severity filter
Same pipeline as regular scan
Design Patterns
Scanner Registry Pattern
What it is: A central registry that scanners add themselves to during package initialization, decoupling the registry from knowledge of specific scanner implementations.
How it works:
Each scanner module (systemd.go, cron.go, etc.) calls Register() in its init() function:
func init() {
Register(&SystemdScanner{})
}
The registry is just a slice:
var registry []types.Scanner
func Register(s types.Scanner) {
registry = append(registry, s)
}
The main.go entry point imports the scanner package with a blank import:
import _ "github.com/CarterPerez-dev/sentinel/internal/scanner"
This triggers all init() functions in the scanner package, populating the registry before main() runs.
Why we chose it: Adding a new scanner requires zero changes to existing code. Create the file, implement the Scanner interface, call Register() in init(). No switch statements, no factory functions, no configuration files. The Go compiler handles discovery.
Trade-offs:
- Pros: Zero-touch registration, impossible to forget to register (it's in the same file as the scanner)
- Cons: Init-time side effects, registration order depends on filename sort order (irrelevant since scanners run in parallel)
Parallel Execution with errgroup
What it is: All 17 scanners run concurrently in separate goroutines, coordinated by golang.org/x/sync/errgroup.
How it works:
var (
mu sync.Mutex
all []types.Finding
g errgroup.Group
)
for _, s := range registry {
g.Go(func() error {
results := s.Scan(root)
mu.Lock()
all = append(all, results...)
mu.Unlock()
return nil
})
}
_ = g.Wait()
Each scanner goroutine independently reads its set of files, applies pattern matching, and collects findings. The mutex protects the shared findings slice. errgroup.Wait() blocks until all goroutines complete.
Why we chose it: Scanners are I/O-bound (reading files from disk). Running them in parallel means the total scan time is roughly the time of the slowest scanner, not the sum of all 17. On a system with SSDs, this cuts scan time dramatically.
Trade-offs:
- Pros: Near-linear speedup for I/O-bound work, simple coordination
- Cons: Findings arrive in non-deterministic order (sorted before display)
Severity-Ranked Pattern Matching
What it is: A single function MatchLine() that tests a line of text against all 16 compiled patterns and returns the highest-severity match.
How it works:
func MatchLine(line string) (matched bool, sev Severity, label string) {
best := SeverityInfo
for _, p := range SuspiciousPatterns {
if p.Pattern.MatchString(line) {
if !matched || p.Severity > best {
best = p.Severity
label = p.Label
}
matched = true
}
}
return matched, best, label
}
When a line matches multiple patterns (a curl piped to bash matches both NetworkToolPattern and DownloadExecPattern), only the highest severity is reported. This prevents double-counting and ensures findings reflect the most dangerous interpretation.
Why we chose it: Centralizing patterns means scanners don't duplicate regex definitions. Adding a new pattern to the engine automatically applies it everywhere: systemd, cron, profile, udev, and every other scanner that calls MatchLine().
Layer Separation
┌────────────────────────────────────┐
│ Layer 1: pkg/types │
│ - Domain types only │
│ - No imports from internal │
└────────────────────────────────────┘
↓
┌────────────────────────────────────┐
│ Layer 2: internal/scanner │
│ - File I/O and pattern matching │
│ - Returns []types.Finding │
│ - No knowledge of CLI or output │
└────────────────────────────────────┘
↓
┌────────────────────────────────────┐
│ Layer 3: internal/cli │
│ - Orchestrates scan pipeline │
│ - Applies filters │
│ - Dispatches to report layer │
└────────────────────────────────────┘
↓
┌────────────────────────────────────┐
│ Layer 4: internal/report + ui │
│ - Presentation only │
│ - Terminal colors, JSON encoding│
└────────────────────────────────────┘
What Lives Where
pkg/types: Severity constants, Finding struct, ScanResult struct, Scanner interface. Imported by everything. Imports nothing internal.
internal/scanner: All 17 scanner implementations, pattern engine, filesystem helpers, registry. Imports only pkg/types. Has no knowledge of CLI flags, output format, or filtering.
internal/config: Ignore-list loading and filtering. Imports pkg/types.
internal/baseline: Snapshot persistence and diff computation. Imports pkg/types.
internal/cli: Cobra command definitions, flag parsing, scan orchestration. Imports scanner, config, baseline, report, ui.
internal/report + ui: Terminal formatting, JSON encoding, color functions, spinner. Imports pkg/types and ui.
Data Models
Finding
type Finding struct {
Scanner string `json:"scanner"`
Severity Severity `json:"severity"`
Title string `json:"title"`
Path string `json:"path"`
Evidence string `json:"evidence"`
MITRE string `json:"mitre"`
}
Fields:
Scanner: Which module produced this finding ("systemd", "cron", "ssh", etc.)Severity: Enum from Info (0) to Critical (4), serializes as int in JSONTitle: Human-readable description of what was foundPath: Absolute filesystem path to the file containing the findingEvidence: The actual line or content that triggered the finding, truncated for displayMITRE: ATT&CK technique ID (e.g., "T1543.002")
ScanResult
type ScanResult struct {
Version string `json:"version"`
ScanTime time.Time `json:"scan_time"`
Hostname string `json:"hostname"`
Findings []Finding `json:"findings"`
Summary SeverityCount `json:"summary"`
DurationMs int64 `json:"duration_ms"`
}
This is the complete output of a scan, used by both the terminal renderer and JSON encoder. It includes metadata (version, hostname, timing) alongside the findings and pre-computed severity counts.
Scanner Interface
type Scanner interface {
Name() string
Scan(root string) []Finding
}
Every scanner module implements this two-method interface. Name() returns a human-readable identifier. Scan() takes a filesystem root path and returns all findings. The root parameter enables scanning mounted filesystems, chroots, or test fixture directories instead of the live system.
Design Decisions
Why a Flat Scanner Package Instead of Sub-Packages
All 17 scanners live in internal/scanner/ as separate files in the same package. An alternative would be internal/scanner/systemd/, internal/scanner/cron/, etc.
What we chose: Single package with one file per scanner.
Why: Scanners share helpers (ReadLines, ListFiles, ResolveRoot, FindUserDirs, ScanFileForPatterns), the pattern engine (MatchLine, all compiled regexes), and the registry (Register). Putting them in separate packages would require exporting all of these or creating a shared utilities package. A single package keeps the shared code unexported and co-located.
Trade-offs: The scanner package has many files, but each file is self-contained and focused. The init() registration pattern means there's no central "list of scanners" to maintain.
Why No External Dependencies for Scanning Logic
The scanner package uses only the standard library (os, path/filepath, strings, regexp, bufio) plus pkg/types. The only external dependencies are in the CLI and UI layers (cobra, fatih/color) and the concurrency layer (errgroup).
Why: The scanning logic must be trustworthy. External dependencies in the detection path could introduce supply chain risk. Keeping the core detection engine dependency-free means it can be audited by reading Go standard library code.
Why Compiled Regexes in Package-Level Variables
Patterns like ReverseShellPattern and DownloadExecPattern are compiled once at package initialization as var declarations with regexp.MustCompile(). An alternative would be compiling them on first use or passing them as parameters.
Why: Regexes are compiled exactly once when the package loads. Every subsequent MatchLine() call uses the compiled automaton. Package-level vars are safe for concurrent reads, and all writes happen before main() runs.
Extensibility
Adding a New Scanner
- Create a new file in internal/scanner/ (e.g.,
docker.go) - Define a struct that implements types.Scanner:
type DockerScanner struct{} func (d *DockerScanner) Name() string { return "docker" } func (d *DockerScanner) Scan(root string) []types.Finding { ... } - Register in init():
func init() { Register(&DockerScanner{}) } - Create test file
docker_test.gowith testdata fixtures
No other files need to change. The registry discovers it automatically.
Adding a New Pattern
Add to internal/scanner/patterns.go:
var NewPattern = regexp.MustCompile(`...`)
Add to the SuspiciousPatterns slice:
{NewPattern, types.SeverityHigh, "description of what this detects"},
Every scanner that calls MatchLine() or ScanFileForPatterns() will immediately start checking for the new pattern.
Key Files Reference
cmd/sentinel/main.go- Entry point, blank import triggers scanner registrationpkg/types/types.go- All domain types (Finding, Severity, Scanner interface)internal/scanner/scanner.go- Registry and parallel RunAll()internal/scanner/patterns.go- All 16 compiled regex patterns and MatchLine()internal/scanner/helpers.go- Shared filesystem utilities and ScanFileForPatterns()internal/cli/root.go- Cobra root command and global flagsinternal/cli/scan.go- Scan subcommand orchestrationinternal/cli/baseline.go- Baseline save/diff subcommandsinternal/baseline/baseline.go- JSON snapshot persistence and diffinternal/config/config.go- Ignore-list loading and filtering
Next Steps
Now that you understand the architecture:
- Read 03-IMPLEMENTATION.md for a walkthrough of the pattern engine, scanner implementations, and baseline diffing code
- Try running
sentinel scan --root testdataand trace the output back to the scanner source code to see the architecture in action