plsec implements security scanning through a typed engine pipeline with cross-layer correlation and preset-driven verdict strategies. This page documents the architecture for practitioners evaluating the system.
plsec is not a scanner. It orchestrates scanners. Each external tool is an adapter behind a common engine interface.
"What to check" (rules, thresholds, patterns) is distinct from "how to check" (scanner invocation, parsing, normalisation). Policy is declarative. Mechanism is imperative.
All engines produce Finding objects. The correlation engine and reporting layer consume Finding objects. No scanner-specific output leaks past the adapter boundary.
Each of the 5 layers is an EngineGroup containing one or more engines. The orchestrator walks layers in order, feeding prior findings forward.
At strict/paranoid, the agent runs inside a container automatically. The preset is the single knob the user turns; everything else follows from it.
Missing tools degrade to a skip, not an error. The engine reports what it could not check, so the operator knows the gap.
The engine architecture is implemented across 13 modules in
src/plsec/engine/ (2,815 lines). These are the
core abstractions.
Universal intermediate representation. Frozen dataclass with deterministic ID via content hash. with_severity() and with_suppressed() for safe copies. Every scanner output normalises to this.
Abstract base for detection/control engines. Stateless. Declares engine_id, layer, presets, dependencies. Methods: check_available(ctx), execute(ctx).
Manages engines within a single security layer. Filters by preset, gates on availability. Handles parallel or sequential execution within the layer.
Scan lifecycle coordinator. Detects environment, builds context, walks layers, correlates, applies policy, computes verdict. The entry point for plsec scan.
Post-detection filter. Answers "which findings matter?" Applies severity floor and suppression rules. Separates detection from judgment.
Outcome interpreter. Three strategies: ThresholdVerdictStrategy (minimal/balanced), StrictVerdictStrategy (strict/paranoid), AuditVerdictStrategy (audit mode).
Cross-layer risk detection. Runs after all layers, produces synthetic findings for compound risks. Example: secret found + no egress control = CRITICAL.
Central catalog. register(), group_for(layer), get(id), build_default_registry(). Adding a scanner means adding one registry entry.
The Orchestrator resolves the active preset, builds an engine plan from the registry, checks tool availability, then walks the layer pipeline. Each layer's findings flow forward to downstream layers. After all layers complete, the Correlation Engine detects compound risks, Policy filters by severity and suppressions, and the Verdict strategy interprets the result.
plsec.yaml + preset resolution | v Orchestrator | 1. Load config + resolve preset | 2. Build engine plan from Registry | 3. Check availability (report gaps) v Layer Pipeline (walks in order, feeds findings forward) | | Layer 1: STATIC ── TrivySecrets, Bandit, Semgrep | Layer 2: CONFIG ── TrivyMisconfig | Layer 3: ISOLATION ── ContainerIsolation | Layer 4: RUNTIME ── (planned) | Layer 5: AUDIT ── (planned) | v Correlation Engine | Cross-layer compound risk detection | (secret + no egress control = CRITICAL) v Policy | Severity floor + suppression rules v Verdict ── PASS / WARN / FAIL / ERROR
Each engine implements the Engine abstract base: declare
dependencies, check availability, execute scan, return
Finding objects. Detection engines (Layers 1-2) scan
for vulnerabilities. Control validation engines (Layers 3-5) verify
security infrastructure is in place.
| Engine ID | Layer | Type | Purpose |
|---|---|---|---|
| trivy-secrets | STATIC | Detection | Leaked credentials, API keys, tokens in source files |
| bandit | STATIC | Detection | Python static analysis (B-class security issues) |
| semgrep | STATIC | Detection | Multi-language SAST (pattern-based security rules) |
| trivy-misconfig | CONFIG | Detection | IaC misconfiguration (Dockerfiles, Kubernetes, Terraform) |
| container-isolation | ISOLATION | Control | Container runtime and config check (Podman/Docker presence) |
| Engine | Layer | Priority | Purpose |
|---|---|---|---|
| DependencyEngine | STATIC | High | pip-audit wrapper for dependency vulnerability scanning |
| AgentConstraintEngine | CONFIG | High | Validates CLAUDE.md/opencode.json deploy and preset match |
| DenyPatternEngine | CONFIG | Medium | File/command deny list verification |
| EgressProxyEngine | RUNTIME | Medium | Validates Pipelock network proxy (strict/paranoid) |
| AuditLogEngine | AUDIT | Medium | Verifies structured audit logging is active |
| SandboxEngine | ISOLATION | Low | macOS sandbox-exec check |
| DLPEngine | RUNTIME | Low | Data loss prevention on agent responses (paranoid only) |
The preset determines both which engines scan and how the agent
executes. At strict/paranoid, plsec run starts the
agent inside a container automatically. The user sets the preset;
everything else follows.
| Engine | Type | Minimal | Balanced | Strict | Paranoid |
|---|---|---|---|---|---|
| TrivySecrets | Detection | x | x | x | x |
| Bandit | Detection | x | x | x | |
| Semgrep | Detection | x | x | x | |
| TrivyMisconfig | Detection | x | x | x | |
| ContainerIsolation | Control | x | x |
| Preset | Execution | Network | Verdict strategy | Fail threshold |
|---|---|---|---|---|
| minimal | Host | Unrestricted | Threshold | CRITICAL |
| balanced | Host | Unrestricted | Threshold | HIGH |
| strict | Container | Restricted | Strict | Any finding |
| paranoid | Container | Egress proxy | Strict | Any finding |
All engines invoking external tools use the extract_json()
pattern. External scanners contaminate stdout with progress bars,
status messages, and ANSI escapes. The three-state handler survives
this:
# Three-state JSON extraction (engine/base.py) def extract_json(stdout: str, returncode: int) -> dict | None: # State 1: Clean JSON in stdout # Parse normally, return findings # State 2: No JSON, exit 0 # Clean scan (no findings), return empty # State 3: No JSON, non-zero exit # Tool failure -- produce a Finding of # category TOOL_ERROR, don't crash # Handles: progress bars before JSON, # status lines mixed with output, # ANSI escapes in error messages
This is documented in detail in docs/secure-tool-handling.md. Every engine test suite covers JSON parsing failures, non-zero exit codes, and prefixed stdout recovery.
| Subsystem | Modules | Lines | Scope |
|---|---|---|---|
| engine/ | 13 | 2,815 | Scan pipeline: types, base, registry, orchestrator, 5 engines, policy, correlation, verdict |
| core/ | 15 | 3,574 | Business logic: config, tools, agents, processes, health, inventory, detector, adapters |
| commands/ | 12 | 3,734 | CLI modules: 11 subcommands as thin wrappers over core |
| configs/ | 3 | 1,418 | Embedded templates: CLAUDE.md, opencode.json, trivy configs, wrappers |
| Suite | Count | Scope |
|---|---|---|
| pytest | 1,232 | Python CLI across 3 tiers: pure logic, filesystem, subprocess mocking |
| BATS unit | 152 | Bootstrap shell script unit tests |
| BATS integration | 88 | Bootstrap end-to-end tests |
| Assembler | 44 | Template escaping edge cases |
Beyond the engine registry, plsec uses a registry-driven architecture
for all entities. Agents, scanners, and processes are declared as
dataclasses in registry modules. Commands iterate registries; they do
not hardcode entity knowledge. Adding a new AI agent means adding one
AgentSpec entry. No if/elif changes in command files.
This was the result of a deliberate refactoring driven by testing: Phase 3 subprocess-mocking tests exposed structural problems (scattered metadata, 35+ files to change for a new agent). The registries reduced "add a new agent" to a single file change.
# Three registries, three entity types AGENTS: dict[str, AgentSpec] # claude, opencode, gemini... SCANNERS: dict[str, ScannerSpec] # trivy-secrets, bandit, semgrep... PROCESSES: dict[str, ProcessSpec] # pipelock, container runtimes... # Commands iterate registries: for agent in resolve_agent_ids(config.agent.type): spec = AGENTS[agent] template = spec.get_template(preset) deploy(project / spec.config_filename, template)