opnDossier Architecture Overview#
Document Purpose#
This document provides a high-level architectural overview of opnDossier, a CLI-based multi-device firewall configuration processor. It covers the key architectural components including parsers, schemas, the CommonDevice model, report plugins, and audit plugins.
1. System Overview#
opnDossier is an offline-first, operator-focused CLI application designed to transform complex XML configuration files from multiple firewall platforms (OPNsense and pfSense) into human-readable markdown documentation, with security analysis and compliance checking capabilities. The system follows a security-first design with complete air-gap compatibility.
Core Design Principles#
- Offline-First: Zero external dependencies, complete air-gap compatibility
- Operator-Focused: Built for network administrators and operators
- Framework-First: Leverages established Go libraries (Cobra, Viper, Charm ecosystem)
- Structured Data: Maintains configuration hierarchy and relationships
- Security-First: No telemetry, comprehensive input validation, secure processing
Architecture Pattern#
opnDossier follows a monolithic CLI application pattern with clear separation of concerns:
- Single binary distribution for easy deployment
- Local processing only with no external network calls
- Streaming data pipeline from XML input to various output formats
CLI Command Architecture#
The system provides multiple first-class commands for different operational workflows:
opndossier convert: Configuration transformation and documentation generationopndossier audit: Dedicated security audit and compliance checking command with support for blue mode (defensive audit) and red mode (attack surface analysis)opndossier display: Interactive viewing of configurationsopndossier diff: Configuration comparison and change detectionopndossier validate: Configuration syntax and semantic validation
The audit command provides a focused interface specifically designed for security and compliance assessments, with two audit modes: blue (defensive audit with security findings and recommendations) and red (attacker-focused recon report).
2. Parsers and Schemas#
2.1 XML Parser Architecture#
The XML parser is implemented in internal/cfgparser/xml.go using a streaming token-based approach rather than DOM parsing for better memory efficiency and security.
Parser Structure#
The XMLParser struct processes XML using streaming tokens:
- Streaming Processing: Reads XML tokens individually rather than loading entire documents into memory
- Character Encoding Support: Supports UTF-8, US-ASCII, ISO-8859-1, and Windows-1252
- Element Routing: The
handleStartElementfunction routes XML elements to corresponding schema fields, handling 30+ configuration sections
Security Features#
The parser implements multiple layers of security protection:
- XXE Prevention: Sets
dec.Entityto an empty map to disallow all entity declarations, preventing XML External Entity attacks - XML Bomb Protection:
- Context-Based Cancellation: Supports timeouts and cancellation to prevent long-running operations
2.2 Schema Structure (XML DTOs)#
opnDossier provides device-specific schema packages for each supported platform:
OPNsense Schema#
The OPNsense schema is defined in pkg/schema/opnsense/ with XML Data Transfer Objects (DTOs) organized across multiple files. These DTOs mirror the OPNsense XML configuration structure with xml: tags.
Root Document#
The OpnSenseDocument struct in pkg/schema/opnsense/opnsense.go serves as the root, containing 30+ configuration sections including system, interfaces, DHCP, firewall, NAT, VPN, certificates, and services.
Key Schema Files#
common.go: Common types likeBoolFlag(custom XML marshaling for OPNsense boolean values)system.go: System configuration including hostname, users, groups, web GUI, SSHinterfaces.go: Network interfaces with custom XML marshaling for map-based interface representationsecurity.go: Firewall rules, NAT, IDS/IPS, IPsecnetwork.go: Gateways, gateway groups, static routes- Additional files:
dhcp.go,vpn.go,certificates.go,services.go,high_availability.go,packages.go,revision.go
XML-Specific Characteristics#
Schema DTOs handle OPNsense XML quirks:
- Presence-based booleans: Empty element means true, absent means false
- Pointer-heavy fields: Uses
*stringto distinguish unset vs. empty values - Map-keyed collections: Interfaces use
map[string]InterfaceItemfor XML unmarshaling - String-encoded values: Many numeric/boolean values stored as strings in XML
pfSense Schema#
The pfSense schema is defined in pkg/schema/pfsense/ following a copy-on-write pattern: reuse OPNsense types where XML structures are identical, fork locally at divergence points.
Root Document#
The Document struct in pkg/schema/pfsense/document.go serves as the root with <pfsense> XML tag (vs OPNsense's <opnsense>). The document reuses 25+ OPNsense types for identical sections (interfaces, dhcpd, snmpd, openvpn, rrd, load_balancer, staticroutes, ppps, gateways, ca, cert, vlans, revision) while forking pfSense-specific types for divergent sections.
Key Structural Differences#
pfSense XML diverges from OPNsense in several areas:
- NAT port forwards: pfSense uses
<nat><rule>(direct child) with<target>field for redirect IP, while OPNsense uses<nat><inbound><rule>(nested) with<internalip>field - User authentication: pfSense uses
<bcrypt-hash>for passwords and per-user<priv>[]arrays for privileges, while OPNsense uses<password>(SHA-based) with group-based privilege model - DNS servers: pfSense uses
<dnsserver>[](repeating XML elements parsed as array), while OPNsense uses single<dnsserver>string - DHCPv6: pfSense includes
RAModeandRAPriorityfields not present in OPNsense - System tunables: Both platforms support kernel tunables, but pfSense stores them at
<system><sysctl><item>[]withtunable/value/descrfields
Schema Reuse Strategy#
The pfSense schema imports pkg/schema/opnsense types extensively to avoid duplication. For example:
Document.Interfacesusesopnsense.Interfaces(identical map-based structure)Document.Dhcpdusesopnsense.Dhcpd(identical per-interface DHCP configuration)OutboundNATreusesopnsense.Outbound(identical structure)- Only fork types where XML structure differs (e.g.,
InboundRulewithTargetfield,FilterRulewith additional fields,Userwith bcrypt authentication)
IPsec VPN Schema#
The pfSense IPsec schema is defined in pkg/schema/pfsense/vpn.go with dedicated types for IPsec tunnel configuration:
IPsec: Top-level container withPhase1[],Phase2[],Client,LoggingIPsecPhase1: IKE Phase 1 (SA) configuration with 27 fields including identity (MyIDType/Data, PeerIDType/Data), crypto (Encryption algorithms with key lengths), timing (Lifetime, RekeyTime, ReauthTime, RandTime), NAT traversal settings, MOBIKE, DPD, certificate references, custom ports, split connectionIPsecPhase2: Phase 2 (child SA) configuration with 20 fields including network identity (LocalID, RemoteID with Type/Address/Netbits), NATLocalID for BINAT, encryption/hash algorithms with key lengths, PFS group, lifetime, ping hostIPsecClient: Mobile/road-warrior client pool with 13 fields including IPv4/IPv6 pools, DNS/WINS servers, split DNS, login banner, password persistenceIPsecLogging: Per-subsystem log level configuration
BoolFlag patterns: Uses presence-based BoolFlag for disabled, mobile, enable, save_passwd elements. All structs with BoolFlag fields implement MarshalXML via type alias pattern (GOTCHAS.md ยง15.1) to ensure addressable fields during marshaling.
See pkg/schema/pfsense/README.md for comprehensive pfSense schema reference including listtag arrays, config version mapping, and detailed field inventories.
Shared XML Security Hardening#
The pkg/parser/xmlutil.go provides shared XML parsing utilities used by both OPNsense and pfSense parsers:
NewSecureXMLDecoder: Creates an*xml.Decoderwith security hardening (input size limit, XXE prevention via empty entity map, charset reader for UTF-8/US-ASCII/ISO-8859-1/Windows-1252)CharsetReader: Handles XML charset declarations for multiple encodings, with normalization for charset name variations
Both device-specific parsers delegate to this shared infrastructure to avoid duplicating security hardening logic.
2.3 Device Parser Factory and Auto-Detection#
The factory in pkg/parser/factory.go provides automatic device type detection and parser delegation using a thread-safe DeviceParserRegistry pattern analogous to the format handling registry in the converter subsystem (see section 4.5.1).
DeviceParserRegistry Pattern#
The factory uses a thread-safe DeviceParserRegistry (pkg/parser/registry.go) to look up parsers instead of hardcoded switch statements. The registry follows the database/sql driver pattern with self-registration:
- Self-Registration: Parsers register themselves via
init()functions (e.g.,pkg/parser/opnsense/parser.gocallsparser.Register("opnsense", NewParserFactory)) - Registry API:
Register(deviceType, fn),Get(deviceType),List(),DefaultRegistry() - Thread-Safe: All operations protected by
sync.RWMutex, singleton initialization viasync.Once - Dynamic Discovery:
List()enables CLI completions and error messages to enumerate supported device types - Test Isolation:
NewFactoryWithRegistry()accepts custom registries for test isolation without global pollution
Critical: Blank Import Requirement
Code using parser.NewFactory() must include blank imports for all desired parsers, or the registry will be empty:
_ "github.com/EvilBit-Labs/opnDossier/pkg/parser/opnsense"
_ "github.com/EvilBit-Labs/opnDossier/pkg/parser/pfsense"
Device-specific parsers only register when their init() runs. Missing blank imports cause "unsupported device type" errors with empty supported lists. The canonical blank imports live in cmd/root.go, but test files using the factory must add their own.
Auto-Detection Mechanism#
The peekRootElementBounded function performs device type detection:
- Uses bounded
LimitReader(10MB cap) for safety - Employs
TeeReaderto buffer consumed bytes - Runs detection in a goroutine with context cancellation support
- Returns a combined reader (buffered bytes + remainder) for full parsing
The createWithAutoDetect method uses registry-based dispatch:
fn, ok := f.registry.Get(rootElem)
if !ok {
return nil, nil, fmt.Errorf(
"unsupported device type: root element <%s> is not recognized; supported: %s",
rootElem, strings.Join(f.registry.List(), ", "),
)
}
return parseDevice(ctx, fn(f.xmlDecoder), fullReader, validateMode)
Error messages dynamically list supported device types from registry.List(), eliminating hardcoded device lists.
Device-Specific Parsers#
Device-specific parsers implement the DeviceParser interface and register themselves with the global registry via init() functions:
-
pkg/parser/opnsense/parser.go: Wraps the XML parser and converts OPNsense schema DTOs to the CommonDevice model. Uses the exportedparser.XMLDecoderinterface (the localxmlDecoderinterface was removed in favor of the public API). -
pkg/parser/pfsense/parser.go: Manages its own XML decoding viaparser.NewSecureXMLDecoderbecause the sharedparser.XMLDecoderinterface returns*schema.OpnSenseDocumentwhich is incompatible withpfsense.Document. The parser decodes directly intopfsense.Documentand converts to CommonDevice via the pfSense-specific converter.
DeviceParser Interface: The DeviceParser interface defines the contract for device-specific parsers:
Parse(ctx, r)returns(*CommonDevice, []ConversionWarning, error)for structural parsingParseAndValidate(ctx, r)returns(*CommonDevice, []ConversionWarning, error)with additional semantic validation
The three-value return signature allows parsers to surface non-fatal conversion issues alongside the parsed device model.
Implementation Reference: See docs/solutions/architecture-issues/pluggable-deviceparser-registry-pattern.md for comprehensive implementation details, testing strategy, and extensibility guidance.
3. CommonDevice Model#
3.1 Architecture and Purpose#
The CommonDevice struct in pkg/model/device.go is the platform-agnostic, normalized representation of firewall configurations. It transforms XML-specific data into clean Go structs suitable for analysis, reporting, and multi-device support.
3.2 Key Design Differences from XML DTOs#
The CommonDevice model differs fundamentally from XML schema DTOs:
| XML Schema (DTOs) | CommonDevice Model |
|---|---|
XML-first with xml: tags | Pure Go structs with json: tags only |
Presence-based booleans (BoolFlag) | Clean bool types |
Map-keyed collections (map[string]Interface) | Ordered slices ([]Interface) |
| String-encoded types | Proper Go types (int, bool) |
| Pointer-heavy for XML unmarshaling | Value types where appropriate |
3.3 Converter Pattern#
The Converter in pkg/parser/opnsense/converter.go implements the transformation from schema DTOs to CommonDevice:
- Main Conversion: The
ToCommonDevicemethod orchestrates 30+ domain-specific converters and returns(*CommonDevice, []ConversionWarning, error)to capture both fatal errors and non-fatal issues - Domain Converters: Each domain has dedicated methods like
convertSystem,convertInterfaces,convertFirewallRules, andconvertVPN - Normalization: Converts XML quirks (BoolFlag, string booleans, int-as-bool) to idiomatic Go types
- Type Safety: Uses typed enum constants instead of string literals throughout conversion (e.g.,
common.RuleTypePass,common.OutboundAutomatic,common.LAGGProtocolLACP), eliminating magic strings at compile boundaries - Map-to-Slice: Converts XML maps to sorted slices for deterministic output
- Warning Accumulation: Uses an
addWarning()method to record non-fatal issues such as missing firewall rule fields, empty gateway addresses, or incomplete NAT configurations
pfSense IPsec Converter#
The pfSense converter in pkg/parser/pfsense/converter_services.go includes dedicated IPsec conversion functions:
convertVPN: Now includes IPsec conversion alongside OpenVPNconvertIPsec: Returns zero-value when no tunnels or mobile client exist, setsEnabled: trueotherwiseconvertIPsecPhase1Tunnels: Maps 27 Phase 1 fields including identity, crypto algorithms with key lengths, timing parameters (rekey/reauth/rand), NAT-T, MOBIKE, DPD, certificate referencesconvertIPsecPhase2Tunnels: Maps 20 Phase 2 fields including network identity with netbits, NATLocalID, encryption/hash algorithms with key lengths, PFS group, ping hostconvertIPsecMobileClient: Maps 13 mobile client fields including IPv4/IPv6 pools, DNS/WINS servers, split DNS, login banner, password persistenceconvertIPsecEncryptionAlgorithms: Shared helper extracting algorithm names with optional key lengths, used by both Phase 1 and Phase 2
Pre-shared key handling: The PreSharedKey field intentionally remains in the schema layer and is not mapped to the common model, relying on the sanitizer to handle sensitive data redaction.
ConversionWarning Type#
The ConversionWarning type in pkg/model/warning.go represents non-fatal issues encountered during schema-to-CommonDevice conversion:
- Field: Dot-path of the problematic field (e.g., "FirewallRules[0].Type")
- Value: The problematic value encountered
- Message: Human-readable description of the issue
- Severity: Importance level using the
analysis.Severitytype (critical, high, medium, low, info)
Warnings are generated for incomplete or problematic data that doesn't prevent conversion but may indicate configuration issues, including:
- Firewall rules with missing type, source, destination, or interface
- NAT rules without internal IP or interface assignment
- Gateways with empty addresses or names
- Users missing name or UID fields
- Certificates with empty PEM data
- High Availability sync targets with missing credentials
3.4 Domain Organization#
CommonDevice organizes configuration into four primary domains:
System Domain#
Defined in pkg/model/system.go:
- Hostname, domain, DNS servers, time servers
- Web GUI configuration (protocol, port, certificate)
- SSH configuration
- Firmware settings
- Hardware offloading options
- System-level toggles (IPv6, console menu, NAT reflection)
Network Domain#
Defined in pkg/model/network.go:
- Interfaces: Physical/logical interfaces with IP configuration
- VLANs: 802.1Q VLAN tagging
- Bridges: Network bridges with STP
- LAGGs: Link aggregation with typed protocol enum (
common.LAGGProtocolLACP,common.LAGGProtocolFailover,common.LAGGProtocolLoadBalance,common.LAGGProtocolRoundRobin) - Virtual IPs: CARP, IP alias, proxy ARP with typed mode enum (
common.VIPModeCarp,common.VIPModeIPAlias,common.VIPModeProxyARP) - Tunnels: GIF/GRE interfaces
- Routing: Gateways, gateway groups, static routes
Security Domain#
Defined in pkg/model/firewall.go and related files:
- FirewallRules: Firewall rules with typed actions (
common.RuleTypePass,common.RuleTypeBlock,common.RuleTypeReject), typed direction (common.DirectionIn,common.DirectionOut,common.DirectionAny), typed IP protocol (common.IPProtocolInet,common.IPProtocolInet6), and normalizedRuleEndpointstructure. Rule types use typed string enums instead of bare string literals for improved type safety. - NAT: Outbound NAT with typed mode enum (
common.OutboundAutomatic,common.OutboundHybrid,common.OutboundAdvanced,common.OutboundDisabled), and inbound NAT (port forwards) with typed IP protocol enums - IDS: Suricata IDS/IPS configuration
- VPN: OpenVPN, WireGuard, IPsec configurations with platform-agnostic tunnel definitions:
- IPsecPhase1Tunnel: 27 fields covering IKE identity, crypto algorithms with key lengths, timing (rekey/reauth/rand), NAT-T, MOBIKE, DPD, certificate references, custom ports, split connection
- IPsecPhase2Tunnel: 20 fields covering network identity with netbits, NATLocalID for BINAT, encryption/hash algorithms with key lengths, PFS group, lifetime, ping host
- IPsecMobileClient: 13 fields covering IPv4/IPv6 virtual address pools, DNS/WINS servers, split DNS, login banner, password persistence
Services Domain#
Defined in pkg/model/services.go:
- DHCP: DHCP server per interface with static leases. Advanced configuration fields now organized into separate
DHCPAdvancedV4andDHCPAdvancedV6structs for better type organization. - DNS: Unbound + dnsmasq configuration
- NTP: Time synchronization
- SNMP: Monitoring
- Syslog: Remote logging
- Monit: Process monitoring
- Netflow: NetFlow/IPFIX accounting
Compliance Domain#
Defined in pkg/model/enrichment.go, the compliance domain contains audit results:
- ComplianceResults: Top-level audit results container with mode, findings, per-plugin results, summary, and metadata
- ComplianceFinding: Individual compliance findings with type, severity, title, description, recommendation, component, and references
- PluginComplianceResult: Per-plugin results including plugin info, findings, summary, controls, and per-control compliance status
- ComplianceControl: Control definitions with ID, title, description, category, severity, rationale, remediation, references, tags, and metadata. Each control includes a
Statusfield populated from theCompliancemap during mapping, with valuesControlStatusPass("PASS") orControlStatusFail("FAIL"). This makes exports self-describing. - ComplianceResultSummary: Aggregate statistics including total findings, findings by severity level, plugin count, and compliant/non-compliant control counts
These types mirror the structure of audit.Report / analysis.Finding / audit.ComplianceResult / compliance.Control but live in the pkg/model package (public API) to avoid circular dependencies with internal/audit/compliance packages. CommonDevice.ComplianceChecks uses *ComplianceResults and is populated by mapAuditReportToComplianceResults() in cmd/audit_handler.go.
4. Report Plugins#
4.1 Report Builder Architecture#
The report generation system in internal/converter/builder/builder.go provides programmatic markdown generation with compile-time type safety.
Core Interfaces#
The builder system follows the Interface Segregation Principle, organizing functionality into three focused sub-interfaces:
-
SectionBuilder(9 methods): Defines methods for building individual configuration sections (BuildSystemSection,BuildNetworkSection,BuildSecuritySection,BuildServicesSection,BuildIPsecSection,BuildOpenVPNSection,BuildHASection,BuildIDSSection,BuildAuditSection) and configuration (SetIncludeTunables) -
TableWriter(11 methods): Defines methods for writing formatted data tables (WriteFirewallRulesTable,WriteInterfaceTable,WriteUsersTable,WriteNATRulesTable,WriteStaticRoutesTable,WriteVLANTable,WriteGatewaysTable,WriteOpenVPNTable,WriteIPsecTable,WriteDHCPSummaryTable,WriteDHCPStaticLeasesTable) -
ReportComposer(2 methods): Defines methods for assembling complete reports (BuildStandardReport,BuildComprehensiveReport) -
ReportBuilderinterface: Composes all three sub-interfaces (SectionBuilder,TableWriter,ReportComposer) for full backward compatibility. Consumers can depend on only the methods they need by accepting narrower interfaces. -
MarkdownBuilderstruct: Concrete implementation satisfying the fullReportBuilderinterface via compile-time assertion -
SectionWriterinterface: Provides streaming output for memory efficiency
Consumer-Local Interface Narrowing#
The HybridGenerator demonstrates consumer-local interface narrowing: its internal builder field uses a private reportGenerator interface (composed of auditBuilder and ReportComposer) that exposes only the 4 methods HybridGenerator actually calls (SetIncludeTunables, BuildAuditSection, BuildStandardReport, BuildComprehensiveReport). The public API (SetBuilder/GetBuilder) still accepts and returns the full ReportBuilder interface for backward compatibility, with GetBuilder using a two-value type assertion to recover the broad interface.
4.2 Report Types#
The MarkdownBuilder generates two primary report types:
Standard Report#
BuildStandardReport includes:
- Header with system information and metadata
- Table of contents
- System, network, security, and services sections
- System tunables
Comprehensive Report#
BuildComprehensiveReport includes all standard sections plus:
- VLANs and static routes
- IPsec VPN configuration
- OpenVPN configuration
- High Availability and CARP
4.3 Section Builders#
Each major configuration area has dedicated build methods:
BuildSystemSection: System configuration, users, groupsBuildNetworkSection: Network interfaces and configurationBuildSecuritySection: NAT, firewall rules, IDS configurationBuildServicesSection: DHCP, DNS, SNMP, NTP, load balancersBuildIPsecSection: IPsec VPNBuildOpenVPNSection: OpenVPN servers and clientsBuildHASection: High Availability and CARPBuildAuditSection: Compliance audit results fromComplianceChecks
Audit Section Rendering#
The BuildAuditSection method in internal/converter/builder/builder.go renders compliance audit results from CommonDevice.ComplianceChecks:
- Returns empty string when
ComplianceChecksis nil (safe to call unconditionally) - Generates summary table with mode and total findings count
- Renders per-plugin compliance results with severity breakdowns
- Unified controls table: When
Controlsdata is available for a plugin, renders a "Plugin Results" table with columns: Control ID, Title, Severity, Category, Status (PASS/FAIL). Replaces the legacy findings-only table. - Failures-only filtering: When
builder.failuresOnlyis true, filters the unified controls table to show only FAIL rows. Displays "All controls compliant" message when all rows are filtered. - Legacy fallback: When
Controlsis empty butFindingsexist, renders the legacy findings table without Status column - Finding partitioning: Partitions findings by
Typefield โ findings withType: "inventory"are rendered in a separate "Configuration Notes" section with a different table structure (Component, Title, Details columns) vs. security findings (Severity, Component, Title, Recommendation columns) - Displays audit metadata in sorted order
- Uses helper functions:
EscapePipeForMarkdown()for pipe-only escaping,TruncateString()for rune-aware exact-position truncation - Plugin names and metadata keys iterated in sorted order via
slices.Sorted(maps.Keys(...))
The corresponding WriteAuditSection method in internal/converter/builder/writer.go provides streaming output for the audit section.
4.4 Formatting Utilities#
The internal/converter/formatters package provides comprehensive formatting:
FormatBool: Checkmarks (โ/โ)FormatBoolStatus: "Enabled"/"Disabled"FormatInterfacesAsLinks: Markdown links to sectionsEscapeTableContent: Markdown special character escapingTruncateDescription: Word-boundary-aware truncationFormatBytes: Human-readable byte formatting
4.5 Multi-Format Output#
The HybridGenerator in internal/converter/hybrid_generator.go provides unified output generation using handler dispatch via handlerForFormat() and the DefaultRegistry. Rather than using switch statements for format selection, the generator delegates to FormatHandler implementations registered in DefaultRegistry, which serves as the single source of truth for supported formats, aliases, file extensions, and validation.
Supported formats:
- Markdown: Via ReportBuilder, with audit section appended when
ComplianceChecksis present - JSON: Direct serialization, automatically includes
ComplianceChecksvia struct tags - YAML: Direct serialization, automatically includes
ComplianceChecksvia struct tags - Plain Text: Stripped markdown using
StripMarkdownFormatting() - HTML: Rendered via goldmark using
RenderMarkdownToHTML()
The GenerateToWriter method enables memory-efficient streaming for large configurations.
4.5.1 FormatRegistry Pattern#
The FormatRegistry in internal/converter/registry.go provides centralized format handling through the DefaultRegistry singleton:
Core Architecture:
FormatHandlerinterface: DefinesGenerate(),GenerateToWriter(),FileExtension(), andAliases()methodsDefaultRegistry: Package-level singleton pre-populated with all built-in format handlers (markdown, json, yaml, text, html)- Handler implementations: Each format has a dedicated handler (
markdownHandler,jsonHandler,yamlHandler,textHandler,htmlHandler) that delegates to the correspondingHybridGeneratormethods
Key Features:
- Single source of truth: All format names, aliases, file extensions, and validation logic centralized in one location
- Alias resolution:
Canonical()method resolves aliases (e.g., "md" โ "markdown", "yml" โ "yaml", "txt" โ "text", "htm" โ "html") - Format validation:
Get()method provides authoritative validation, returningErrUnsupportedFormatfor unknown formats - Extensibility: Adding a new format requires only registering a
FormatHandlerinnewDefaultRegistry()โ all validation, completion, and dispatch automatically adopt it - Panic-on-duplicate: Follows
database/sqldriver pattern with init-time panics for duplicate format names, aliases, or nil handlers
Integration Points:
- CLI commands (
cmd/convert.go,cmd/shared_flags.go) use the registry for format validation, completions, and file extension mapping Options.Format.Validate()delegates toDefaultRegistry.Get()processor.Transform()usesDefaultRegistry.Canonical()for alias resolution- Config validation derives
ValidFormatsfromDefaultRegistry.ValidFormats()
See AGENTS.md ยง5.9b for detailed implementation guidance.
Audit Section Integration#
The HybridGenerator integrates audit sections based on output format:
- Markdown:
generateMarkdownandgenerateMarkdownToWriterappend audit sections viaBuildAuditSection()/WriteAuditSection()whenComplianceChecksis present - JSON/YAML: Audit data serialized automatically through
CommonDevice.ComplianceChecksfield with no format-specific code needed
4.6 Data Enrichment#
The prepareForExport function in internal/converter/enrichment.go enriches CommonDevice before JSON/YAML export:
- Statistics: Interface counts, rule stats, user/group counts (computed via
analysis.ComputeStatistics) - Analysis: Security findings, configuration insights (computed via
analysis.ComputeAnalysis) - SecurityAssessment: Computed security scores
- Redaction: Replaces sensitive data (passwords, keys, SNMP community strings) with
[REDACTED] - ComplianceChecks: Pass-through field populated by audit handler (
cmd/audit_handler.go), not modified during enrichment
Shared Analysis Package#
The converter delegates to internal/analysis/ for statistics computation and detection logic:
ComputeStatistics(): Computes interface, rule, user, group, service, gateway, and system statisticsComputeAnalysis(): Orchestrates all detection functions and returns a completeAnalysisDetectDeadRules(): Identifies unreachable rules (after block-all) and duplicatesDetectUnusedInterfaces(): Finds enabled interfaces not used in rules or servicesDetectSecurityIssues(): Detects insecure configurations (HTTP web GUI, default SNMP community, overly permissive WAN rules)DetectPerformanceIssues(): Identifies performance impacts (disabled offloading, high rule counts)DetectConsistency(): Validates gateway formats, DHCP-interface consistency, user-group relationshipsRulesEquivalent(): Compares firewall rules for functional equivalence (includingDisabledfield)
The shared internal/analysis/ package was created in PR #409 to eliminate duplicated logic between the converter enrichment pipeline and the processor's analyze phase. Both subsystems now import and delegate to the same detection functions for consistent behavior.
5. Audit Plugins#
5.1 Audit Plugin Architecture#
The audit plugin system in internal/compliance/interfaces.go provides a plugin-based compliance checking framework for auditing firewall configurations against industry security standards.
Core Interface#
All compliance plugins implement the Plugin interface:
type Plugin interface {
Name() string
Version() string
Description() string
RunChecks(device *common.CommonDevice) []Finding
GetControls() []Control
GetControlByID(id string) (*Control, error)
ValidateConfiguration() error
}
Plugin Execution Model: Each plugin's RunChecks() method is executed within a panic recovery boundary to provide isolation between plugins and the audit engine. This ensures that a misbehaving plugin (especially dynamically-loaded) cannot crash the entire audit process. The audit engine requires a *slog.Logger parameter to log panic events.
Key data structures:
Control: Compliance control with ID, title, severity, rationale, remediation. Controls can haveType: "inventory"for informational observations that do not affect compliance status (excluded fromEvaluatedControlIDs).Finding: The canonical Finding struct is defined ininternal/analysis/finding.go, with standardized fields including type (category), severity level, title, description, recommendation, component, and reference. Thecompliance.Findingtype is now a type alias toanalysis.Finding, ensuring consistency across all plugins. TheSeverityfield uses theanalysis.Severitytype with values: "critical", "high", "medium", "low", "info"
Inventory Controls vs Info-Severity Findings#
The architecture distinguishes between inventory controls and info-severity findings:
-
Inventory controls (
Type: "inventory"): Informational observations that are excluded from compliance evaluation entirely. Their control IDs never appear inEvaluatedControlIDsand do not participate in the compliance map (no PASS/FAIL status). Example: FIREWALL-062 (DHCP Scope Inventory), FIREWALL-063 (Active Interface Summary). Inventory findings render in a separate "Configuration Notes" section. -
Info-severity findings (
Severity: "info"): Participate in compliance evaluation normally โ they can PASS or FAIL. Severity affects only presentation priority (summary counts, sort order), NOT compliance gating. The compliance flip inRunComplianceChecksapplies to all severities equally. Example: FIREWALL-003 (Message of the Day) was reclassified from "low" to "info" in PR #510 to reflect its informational nature, but still participates in compliance evaluation.
See GOTCHAS.md ยง2.4 for the rationale behind this design decision.
Plugin Registry#
The PluginRegistry in internal/audit/plugin.go manages plugins:
- Thread-safe registration with
sync.RWMutex - Plugin validation on registration
- Dynamic
.soplugin loading support viaLoadDynamicPlugins(ctx, dir, explicitDir, logger) (LoadResult, error) - Global registry singleton for convenience
- Injectable
pluginLoader pluginLoaderFuncfield for testing without real.sofiles newPluginRegistryWithLoader(loader)constructor for dependency injection in tests
Thread-Safety and Initialization#
The global PluginRegistry singleton uses sync.Once for initialization via GetGlobalRegistry(). This provides happens-before memory guarantees per the Go memory model: all writes within the initialization function are visible to every goroutine that subsequently calls GetGlobalRegistry() without additional synchronization.
Lifecycle contract: Plugins should be registered at startup (initialization phase) via RegisterGlobalPlugin() before concurrent access begins. While the internal sync.RWMutex makes concurrent reads and writes technically safe, the intended usage pattern is register-at-startup, read-during-operation.
PluginManager vs Global Registry#
The architecture maintains two independent PluginRegistry instances:
-
Global Singleton: Accessed via
GetGlobalRegistry()and populated usingRegisterGlobalPlugin(). Provides package-level convenience access to plugins. -
PluginManager's Registry: Each
PluginManagerallocates its own independentPluginRegistryinstance. TheInitializePlugins()method populates this manager-specific registry with built-in plugins (STIG, SANS, Firewall) during sequential startup. IfSetPluginDir(dir, explicit)was called beforehand,InitializePlugins()also loads dynamic.soplugins from the configured directory.
Usage guidance:
- Use the global singleton for package-level plugin access where centralized registration is appropriate
- Use PluginManager's instance for managed lifecycle operations with isolated plugin state per manager instance
- These registries are separate: registering a plugin with
RegisterGlobalPlugin()does not add it to aPluginManager's registry and vice versa
Dynamic Plugin Loading#
The LoadDynamicPlugins method returns (LoadResult, error) to report both successful and failed plugin loads:
- LoadResult: Contains
Loadedcount andFailures []PluginLoadErrorslice - LoadResult.Failed(): Method (not field) returning
len(Failures)for convenience - PluginLoadError: Per-plugin error type implementing the
errorinterface, withName(filename) andErrfields - Aggregate errors: Multiple load failures are joined via
errors.Join
explicitDir parameter: Controls error handling when the plugin directory does not exist:
explicitDir=false: Silently skip missing directory (Debug log), used for default/optional pathsexplicitDir=true: Return error for missing directory (fail-fast), used when user explicitly specifies--plugin-dir
pluginLoaderFunc abstraction: The registry injects plugin loading logic via the pluginLoaderFunc type:
defaultPluginLoader: Production loader opening.sofiles viaplugin.OpennewPluginRegistryWithLoader(loader): Test constructor accepting custom loaders for deterministic testing without real.sofiles- Nil plugin guard: Loaders returning
(nil, nil)are treated as failures, preventing panics
Two-Phase CLI Validation Pattern#
The audit command validation in cmd/audit.go follows a two-phase validation pattern to support dynamic plugins:
Phase 1: PreRunE (early static validation)
- Validates audit mode against known modes (
blue,red) - Enforces
--plugins/--modecoupling (plugins only work with blue mode) - Rejects multi-file +
--outputconflicts - Validates shared output flags (format, wrap, section)
- Does NOT validate plugin names โ plugin validation is deferred to phase 2
Phase 2: Post-initialization (runtime validation)
ValidateModeConfig()ininternal/audit/mode_controller.govalidatesSelectedPluginsagainst the live registry afterInitializePlugins()populates it with both built-in and dynamic plugins- Returns
ErrPluginNotFoundfor unrecognized plugin names - Called by
GenerateReport()during theRunEphase viahandleAuditMode()
Rationale: Plugin name validation requires the registry to be populated with both built-in and dynamically-loaded plugins. Since PreRunE executes before InitializePlugins() loads dynamic .so plugins from --plugin-dir, validating plugin names in PreRunE would reject valid dynamic plugin names before they are loaded. Deferring validation to post-initialization allows dynamic plugins to be discovered, registered, and validated against the live registry.
Shell completions: The ValidAuditPlugins() function uses registryPluginNames() to source plugin names from a temporary PluginManager initialized with built-in plugins, mirroring the ValidDeviceTypes registry-driven pattern. This ensures shell completions stay synchronized with the live registry. Dynamic plugins are not included in completions because shell completion runs before --plugin-dir is processed.
See docs/solutions/logic-errors/cli-prerun-validation-timing-dynamic-plugins.md for comprehensive implementation details and prevention strategies.
5.2 Built-in Audit Plugins#
opnDossier includes three built-in compliance plugins:
Firewall Plugin (internal/plugins/firewall/)#
The Firewall plugin provides security controls (FIREWALL-001 through FIREWALL-063):
Security Controls (standard compliance evaluation):
- FIREWALL-001: SSH Warning Banner Configuration (medium)
- FIREWALL-002: Auto Configuration Backup (medium)
- FIREWALL-003: Message of the Day (info)
- FIREWALL-004: Hostname Configuration (low)
- FIREWALL-005: DNS Server Configuration (medium)
- FIREWALL-006: IPv6 Disablement (medium)
- FIREWALL-007: DNS Rebind Check (low)
- FIREWALL-008: HTTPS Web Management (high)
- Additional controls through FIREWALL-061
Inventory Controls (Type: "inventory", excluded from compliance evaluation):
- FIREWALL-062: DHCP Scope Inventory โ reports configured DHCP scopes and interfaces
- FIREWALL-063: Active Interface Summary โ reports enabled interfaces and types
Uses a three-state check pattern (checkResult{Result, Known}) to prevent false positives when data is insufficient. All findings derive their Severity from control metadata via the controlSeverity() helper, ensuring consistency with control definitions.
SANS Plugin (internal/plugins/sans/)#
The SANS plugin implements SANS Firewall Checklist (SANS-FW-001 through SANS-FW-004):
- SANS-FW-001: Default Deny Policy (high)
- SANS-FW-002: Explicit Rule Configuration (medium)
- SANS-FW-003: Network Zone Separation (high)
- SANS-FW-004: Comprehensive Logging (medium)
All findings derive their Severity from control metadata via the controlSeverity() helper.
STIG Plugin (internal/plugins/stig/)#
The STIG plugin implements Security Technical Implementation Guide (V-206xxx series):
- V-206694: Default deny policy (high)
- V-206674: Specific packet filtering (high)
- V-206690: Disable unnecessary services (medium)
- V-206682: Comprehensive logging (medium)
Most mature implementation with sophisticated checks:
- Overly permissive rules (any/any, broad ranges)
- Unnecessary services (insecure SNMP, DNSSEC stripping)
- Logging configuration with four-state enum
All findings derive their Severity from control metadata via the controlSeverity() helper.
5.3 Compliance Checking Workflow#
The compliance check execution in internal/audit/plugin.go:
- Validate device is non-nil
- Deduplicate plugin names (case-insensitive)
- For each selected plugin:
- Call
plugin.RunChecks(device)to get findings, wrapped in panic recovery - Panic Recovery: Each
RunChecks()call is wrapped indefer recover()to isolate plugin failures. If a plugin panics, it is logged via structured logging and retained in the result with zero findings - Invariant: Every selected plugin always appears in the final results (
PluginFindings,PluginInfo,Compliance, summary maps), even if it panics. This ensures downstream consumers can see all requested plugins were evaluated - Normalize findings: Call
deriveSeverityFromControl()to populate missingFinding.Severityvalues from control metadata - Return errors if control references cannot be resolved
- Store findings in both aggregate (
Findings) and per-plugin (PluginFindings) maps - Track plugin metadata (name, version, deep-cloned controls)
- Initialize compliance tracking (all evaluated controls default to compliant)
- Mark controls as non-compliant based on findings: Inventory findings (
Type: "inventory") are explicitly skipped during the compliance flip โ their referenced controls are not inEvaluatedControlIDsand do not participate in pass/fail status
- Call
- Calculate summary statistics with per-plugin granularity:
- Aggregate findings by severity using
countSeverities()helper - Per-plugin severity breakdown via
computePerPluginSummary() - Compliance percentages per plugin
- Aggregate findings by severity using
- Return
ComplianceResultwith findings, per-plugin findings map, compliance map, summary, and plugin info
As of PR #391, the Finding and Severity types have been unified across the codebase. The canonical types are defined in internal/analysis/finding.go, with compliance.Finding and processor.Finding implemented as type aliases. This eliminates previous duplication across audit, compliance, and processor packages, ensuring consistent finding representation throughout opnDossier.
5.4 Plugin Manager#
The PluginManager in internal/audit/plugin_manager.go handles plugin lifecycle:
SetPluginDir(dir, explicit): Configures dynamic plugin directory before initialization. Theexplicitflag controls error handling for missing directories (true = fail-fast for user-specified paths, false = silent skip for default paths).InitializePlugins(): Registers built-in plugins (STIG, SANS, Firewall) and loads dynamic.soplugins ifSetPluginDirwas called. Dynamic plugin load failures are non-fatal โ callers must checkGetLoadResult()after initialization to detect per-plugin failures.GetLoadResult(): ReturnsLoadResultfrom the most recentLoadDynamicPluginscall, containing counts of loaded/failed plugins and per-plugin failure details.RunComplianceAudit(): Executes compliance checks for selected pluginsListAvailablePlugins(): Returns metadata about registered pluginsGetPluginStatistics(): Provides plugin usage statistics
5.5 Audit Execution Paths#
opnDossier provides two distinct CLI entry points for running security audits and compliance checks, both using the same underlying audit/compliance plugin architecture:
Dedicated Audit Command#
The audit command in cmd/audit.go provides a focused interface for security and compliance assessments:
Command Structure:
cmd/audit.go: Command definition, flag variables,init(),PreRunEvalidation,runAudit()orchestration,generateAuditOutput()parsing/audit executioncmd/audit_output.go: Output emission logic (emitAuditResult()), path derivation (deriveAuditOutputPath()), tilde-based segment escaping (escapePathSegment())
Key Features:
- Multi-file audit support: Processes multiple configuration files concurrently with semaphore-based concurrency limiting
- Auto-naming for multi-file runs: Derives unique output paths using tilde-based escaping to prevent filename collisions (e.g.,
some~upath_config-audit.md) - Glamour-rendered terminal output: Markdown reports are styled for terminal display when written to stdout; file output and non-markdown formats remain raw
- Plugin mode coupling:
--pluginsflag only works with--mode blue(enforced inPreRunE); red mode ignores compliance plugins - Default plugin behavior: Bare
--mode blueruns all available plugins when no--pluginsspecified --failures-onlyflag: Filters blue mode compliance tables to show only non-compliant controls. Only valid with blue mode and markdown format (enforced inPreRunE).
Validation:
PreRunEvalidation: Audit mode, plugin names, multi-file +--outputrejection, plugin-mode coupling,--failures-onlyrestrictionsvalidateOutputFlags(): Shared flag validation (format, wrap, section) delegated tocmd/shared_flags.go
Convert Command Audit Integration#
The convert command no longer supports audit mode. For security audits and compliance checking, use the dedicated audit command instead.
Shared Audit Handler#
The handleAuditMode function in cmd/audit_handler.go orchestrates audit report generation:
- Plugin Manager Setup: Creates
PluginManagerand callsSetPluginDir(auditOpts.PluginDir, auditOpts.ExplicitPluginDir)if--plugin-dirflag was provided - Plugin Initialization: Calls
InitializePlugins()to register built-in plugins and load dynamic.soplugins - Load Failure Surfacing: Checks
GetLoadResult()after initialization and logs warnings for any failed dynamic plugin loads with filenames - Compliance Execution: Runs compliance checks via
PluginManager.RunComplianceAudit(), producing anaudit.Report - Result Mapping: Calls
mapAuditReportToComplianceResults()to convertaudit.Reportโcommon.ComplianceResults, mapping:- Top-level findings from
audit.Findingtocommon.ComplianceFinding - Per-plugin results from
audit.ComplianceResulttocommon.PluginComplianceResult - Controls from
compliance.Controltocommon.ComplianceControl - Summary statistics with compliant/non-compliant counts
- Top-level findings from
- Device Population: Assigns
ComplianceResultstodevice.ComplianceChecks - Report Delegation: Calls
generateWithProgrammaticGenerator()for format-agnostic report generation
The handler contains no format-specific rendering code. All markdown generation happens in internal/converter/builder/, and JSON/YAML serialization occurs automatically via struct tags on CommonDevice.ComplianceChecks.
Shared Flag Validation#
The validateOutputFlags() function in cmd/shared_flags.go validates format/wrap/section flag combinations shared between audit and convert commands:
- Mutual exclusivity: Rejects
--no-wrap+--wrapcombinations - Format validation: Checks format against
converter.DefaultRegistry.ValidFormatsWithAliases() - Section compatibility warnings: Warns when
--sectionis used with JSON/YAML (sections ignored) - Wrap width validation: Warns for out-of-range values, errors for invalid values
Command-specific validation (audit mode, plugin names) remains in each command's PreRunE.
Multi-File Output Path Derivation#
The deriveAuditOutputPath() function in cmd/audit_output.go computes unique output filenames for multi-file audit runs:
- Tilde-based escaping: Uses
~~for literal tildes,~ufor underscores, freeing_as an unambiguous segment separator - Collision prevention: Avoids double-underscore ambiguity (e.g.,
a_/bvsa/_bboth producinga___b) - Absolute path handling: Prefixes absolute paths with
~amarker - Bare filenames: Simple names for files without directory components (e.g.,
config-audit.md)
CLI Integration#
The --plugin-dir flag (registered in cmd/shared_flags.go) specifies the directory containing dynamic .so compliance plugins. The flag value is wired through audit.Options.PluginDir and audit.Options.ExplicitPluginDir, with the latter set to true when the user explicitly provides the flag. This enables fail-fast behavior for user-specified directories while allowing default/optional paths to silently skip.
5.6 Mode Controller#
The ModeController in internal/audit/mode_controller.go orchestrates report generation with two supported modes:
- Blue mode: Defensive audit with compliance checks and security findings (default)
- Red mode: Attack surface analysis, exposed services, weak configurations
The Finding struct in mode_controller.go embeds analysis.Finding for common fields (Title, Severity, Description, Recommendation, Component, Tags) and adds audit-specific extensions (AttackSurface, ExploitNotes, Control).
5.7 Validation Patterns#
The framework implements several validation patterns:
- Three-state checks: Returns
{Result, Known}to distinguish non-compliant from "cannot determine" - Nil-safe checks: All helpers validate device is non-nil before accessing fields
- Conservative approach: When uncertain, assumes compliance rather than false positives
- Plugin validation: All plugins pass
ValidateConfiguration()on registration - Standardized findings: Uniform
Findingstructure for consistent processing
6. Data Flow Architecture#
The complete data flow through opnDossier follows this pattern:
Processing Pipeline#
- Input: Firewall XML configuration file (OPNsense or pfSense)
- Factory: Auto-detects device type by peeking at root XML element (
<opnsense>or<pfsense>). TheCreateDevicemethod returns(*CommonDevice, []ConversionWarning, error)to propagate warnings through the pipeline. - XML Parser: Device-specific parsers (OPNsense, pfSense) stream XML tokens into schema DTOs with security protections via shared
NewSecureXMLDecoder - Converter: Device-specific converters (OPNsense, pfSense) transform XML DTOs to CommonDevice model, accumulating non-fatal warnings for incomplete or problematic data
- Analysis (optional):
- Audit plugins analyze CommonDevice for compliance violations
- Data enrichment computes statistics
- Report Generation:
- MarkdownBuilder generates sections from CommonDevice with dynamic platform names via
DeviceType.DisplayName() - HybridGenerator produces output in selected format
- MarkdownBuilder generates sections from CommonDevice with dynamic platform names via
- Output: Human-readable documentation in markdown, JSON, YAML, HTML, or plain text
- Warning Handling: CLI commands (
convert,diff,display,validate) log conversion warnings via structured logging (ctxLogger.Warn), suppressed in quiet mode
7. Key Architectural Benefits#
- Security-First Design: XXE prevention, XML bomb protection, streaming processing, offline operation
- Platform Extensibility: Clean separation between XML DTOs and CommonDevice enables multi-platform support
- Plugin Architecture: Extensible compliance checking via interface-based plugins with dynamic loading
- Type Safety: Compile-time validation through programmatic generation, strong Go typing, and typed enum constants for firewall rules, NAT modes, DHCP configuration, network protocols, and VIP modes. This refactoring eliminated magic strings across 70 files, reducing runtime errors and improving code maintainability.
- Memory Efficiency: Streaming parser and writer support large configurations without memory exhaustion
- Separation of Concerns: Clear boundaries between parsing (schema), modeling (common), analysis (audit), and presentation (converter)
- Conservative Validation: Three-state check pattern prevents false positives in compliance checking
- Multi-Format Output: Single data model supports markdown, JSON, YAML, HTML, and plain text
- Plugin Isolation: Panic recovery in compliance checking prevents individual plugin failures from crashing the audit process