Documents
Audit Plugin Architecture
Audit Plugin Architecture
Type
Topic
Status
Published
Created
Feb 27, 2026
Updated
Apr 19, 2026
Created by
Dosu Bot
Updated by
Dosu Bot

Audit Plugin Architecture#

The Audit Plugin Architecture is the extensible plugin system used by opnDossier for performing compliance checks on OPNsense firewall configurations. The architecture enables developers to create custom compliance plugins that integrate seamlessly with the core audit engine, supporting both statically compiled plugins and dynamically loaded plugins at runtime.

All plugins implement the compliance.Plugin interface defined in internal/compliance/interfaces.go, which standardizes how compliance checks are executed, how findings are reported, and how controls are managed. Plugins can be either statically registered (compiled into the binary) or dynamically loaded at runtime as Go plugins (.so files), providing flexibility for both built-in compliance frameworks and third-party extensions.

The architecture demonstrates best-practice design with loose coupling, standardized data structures, thread-safe plugin registration, and validation at registration time. The plugin execution layer includes panic recovery to isolate misbehaving plugins, preventing a single plugin failure from crashing the entire audit process. Dynamic plugin load failures are surfaced to CLI users via warning logs and returned through the LoadResult structure, enabling production deployments to detect and respond to plugin loading issues.

Architecture Overview#

Design Philosophy#

The plugin architecture is built on several core design principles that enable extensibility while maintaining system stability:

Core Components#

Plugin Interface Contract#

compliance.Plugin Interface#

The compliance.Plugin interface defines seven required methods that all plugins must implement:

type Plugin interface {
    Name() string
    Version() string
    Description() string
    RunChecks(device *common.CommonDevice) (findings []Finding, evaluated []string, err error)
    GetControls() []Control
    GetControlByID(id string) (*Control, error)
    ValidateConfiguration() error
}

Method Descriptions:

  • Name() - Returns a unique plugin identifier used for registry lookups and reporting
  • Version() - Returns the plugin version string for compatibility tracking
  • Description() - Provides a human-readable description of the plugin's purpose
  • RunChecks() - Executes all compliance checks against the provided device configuration in a single traversal. Returns three values: a findings slice containing standardized findings, an evaluated slice containing the IDs of controls that were evaluated (even if they passed), and an error if the check execution failed. Controls returned by GetControls() that are not in the evaluated slice are reported as UNKNOWN in the audit report.
  • GetControls() - Returns the complete list of compliance controls implemented by the plugin
  • GetControlByID() - Retrieves a specific control by its unique identifier
  • ValidateConfiguration() - Validates the plugin's configuration and returns an error if invalid

Control Structure#

The Control struct represents individual compliance controls with comprehensive metadata:

type Control struct {
    ID string `json:"id"`
    Title string `json:"title"`
    Description string `json:"description"`
    Category string `json:"category"`
    Severity string `json:"severity"`
    Rationale string `json:"rationale"`
    Remediation string `json:"remediation"`
    References []string `json:"references,omitempty"`
    Tags []string `json:"tags,omitempty"`
    Metadata map[string]string `json:"metadata,omitempty"`
}

Controls define the compliance requirements being checked, including severity levels (critical, high, medium, low, info), rationale for the requirement, and actionable remediation guidance.

Control Types:

Controls may include an optional Type field to distinguish different control categories:

  • Compliance controls (default, Type omitted or "compliance"): Security requirements that are evaluated for pass/fail compliance status and included in the compliance map
  • Inventory controls (Type: "inventory"): Informational observations that document configuration state but do not affect pass/fail compliance status. Inventory controls are excluded from the evaluated slice and the compliance map, and are rendered in a separate "Configuration Notes" section in audit reports rather than "Security Findings".

Finding Structure#

The canonical Finding struct is defined in internal/analysis/finding.go and provides a unified finding representation across audit, compliance, and processor packages:

type Finding struct {
    Type string `json:"type"`
    Severity string `json:"severity,omitempty"`
    Title string `json:"title"`
    Description string `json:"description"`
    Recommendation string `json:"recommendation"`
    Component string `json:"component"`
    Reference string `json:"reference"`
    References []string `json:"references,omitempty"`
    Tags []string `json:"tags,omitempty"`
    Metadata map[string]string `json:"metadata,omitempty"`
}

Type System Unification (PR #391):

As of PR #391, the finding type hierarchy has been unified:

JSON Serialization Note:

The Recommendation, Component, and Reference fields intentionally omit the omitempty JSON tag in analysis.Finding, which differs from the previous processor.Finding behavior. This ensures consistent serialization across all packages.

Findings are linked to controls through the References field, which contains a list of control IDs. The Type field typically contains "compliance" for security compliance findings, or "inventory" for informational configuration observations that do not affect pass/fail compliance status.

The Severity field is required and must be populated by plugins with one of the standard severity levels defined in analysis.Severity. Plugins derive this value from the associated control's Severity field to maintain a single source of truth for severity definitions. If a plugin returns a finding without a severity value, the audit engine will attempt to derive it from the control metadata referenced in References or Reference fields. The deriveSeverityFromControl() function includes IsValidSeverity() validation checks and returns an error if the control specifies an unrecognized severity value.

Note that the Finding struct does not have a Plugin field—plugin association is tracked separately via the PluginInfo map in ComplianceResult.

Plugin Registry#

The PluginRegistry provides thread-safe plugin registration and retrieval using sync.RWMutex:

type PluginRegistry struct {
    plugins map[string]compliance.Plugin
    mutex sync.RWMutex
}

Registration Process#

The RegisterPlugin() method validates plugins before registration and prevents duplicate registrations:

func (pr *PluginRegistry) RegisterPlugin(p compliance.Plugin) error {
    pr.mutex.Lock()
    defer pr.mutex.Unlock()

    if err := p.ValidateConfiguration(); err != nil {
        return fmt.Errorf("plugin validation failed for %s: %w", p.Name(), err)
    }

    if _, exists := pr.plugins[p.Name()]; exists {
        return fmt.Errorf("plugin %s is already registered", p.Name())
    }

    pr.plugins[p.Name()] = p
    return nil
}

Plugin Retrieval#

The GetPlugin() method uses read locks (RLock) for efficient concurrent access. When a plugin is not found, it returns ErrPluginNotFound, a sentinel error defined in the compliance package.

Global Registry (Deprecated)#

Deprecated: The global registry singleton and its accessor functions (GetGlobalRegistry(), RegisterGlobalPlugin(), GetGlobalPlugin(), ListGlobalPlugins()) are scheduled for removal in v2.0. They are retained only for backward compatibility with legacy tests. New code must use NewPluginManager(logger, reg) with an explicit *PluginRegistry.

A global registry singleton (GlobalRegistry) provides package-level access to a shared PluginRegistry instance. The singleton is initialized on first access using sync.Once, which provides strong thread-safety guarantees.

Thread-Safety Guarantees:

The global registry uses sync.Once to ensure initialization occurs exactly once with happens-before memory guarantees per the Go memory model (ref). This means:

  • The assignment of globalRegistry inside sync.Once.Do() is visible to every goroutine that subsequently calls GetGlobalRegistry() without additional synchronization
  • Multiple concurrent calls to GetGlobalRegistry() are safe—sync.Once ensures initialization completes before any caller receives the pointer
  • Subsequent calls return the same *PluginRegistry instance without synchronization overhead

Dynamic Plugin Loading#

The LoadDynamicPlugins() method loads .so files from a specified directory, enabling runtime plugin extensibility. The method signature is:

func (pr *PluginRegistry) LoadDynamicPlugins(
    ctx context.Context,
    dir string,
    explicitDir bool,
    logger *logging.Logger,
) (LoadResult, error)

Parameters:

  • ctx - Context for cancellation and timeout control
  • dir - Directory path to scan for .so files
  • explicitDir - Controls missing directory behavior:
    • true: Missing directory returns an error (fail-fast when user explicitly configured the path)
    • false: Missing directory is silently skipped with Debug-level logging (optional/default paths)
  • logger - Logger instance for diagnostics (returns error if nil)

Loading Process:

  1. Validates that a non-nil logger was provided
  2. Scans the plugin directory for .so files (non-.so files are ignored)
  3. Preflight hardening: Before plugin.Open() is called, each plugin file undergoes validation checks:
    • Symlink rejection: Rejects symlinks with a fatal error (all platforms)
    • Permission checks (POSIX only): Rejects files or parent directories with group/world-writable permissions (mode & 0o022 != 0)
    • Absolute path requirement: Rejects relative paths
    • SHA-256 hashing: Computes a SHA-256 digest of the plugin file with a 64 MiB size cap for audit logging
    • Structured audit log: Emits a JSON-structured log entry for every load attempt (INFO for accepted, WARN for rejected) with metadata: filename, path, SHA-256 hash, file mode, owner UID, modification time, size, verdict, and reason
  4. Opens each plugin using the configured pluginLoaderFunc (default: defaultPluginLoader)
  5. Looks up the exported Plugin symbol and validates it implements compliance.Plugin
  6. Registers the plugin via RegisterPlugin()
  7. Collects per-plugin failures in LoadResult.Failures
  8. Returns an aggregate error via errors.Join if any plugins failed

Return Values:

The method returns (LoadResult, error):

  • LoadResult - Structure containing load statistics and per-plugin failure details
  • error - Aggregate error combining all individual plugin failures via errors.Join, or nil if all succeeded

Error Handling Behavior:

The implementation uses a "best-effort" approach where individual plugin failures do not prevent loading other plugins:

  • Per-plugin failures (load errors, registration failures) are captured in LoadResult.Failures
  • Execution continues with remaining plugins after a failure
  • All per-plugin errors are aggregated via errors.Join and returned
  • Directory-level errors (missing explicit directory, unreadable directory) are fatal and return immediately
  • A nil plugin from the loader is treated as a failure (not a panic)

LoadResult Structure:

type LoadResult struct {
    Loaded int
    Failures []PluginLoadError
}

Fields:

  • Loaded - Number of plugins successfully loaded and registered
  • Failures - Slice of per-plugin load failures with details

Methods:

  • Failed() int - Returns the count of failed plugins (equivalent to len(Failures))

PluginLoadError Structure:

type PluginLoadError struct {
    Name string
    Err error
}

The PluginLoadError type implements the error interface, enabling clean aggregation with errors.Join. The Error() method returns a formatted string: "plugin <Name>: <Err>".

Testability via Plugin Loader Injection:

The PluginRegistry uses a pluginLoaderFunc type to abstract the plugin loading mechanism:

type pluginLoaderFunc func(path string) (compliance.Plugin, error)

This enables deterministic testing without requiring real .so files. Tests can inject a fake loader via the newPluginRegistryWithLoader(loader pluginLoaderFunc) constructor. The production implementation uses defaultPluginLoader, which performs the standard open → lookup → type-assert pipeline.

Plugin Execution with Panic Recovery#

The RunComplianceChecks() method executes compliance checks for selected plugins with built-in panic isolation:

func (pr *PluginRegistry) RunComplianceChecks(
    device *common.CommonDevice,
    pluginNames []string,
    logger *slog.Logger,
) (*ComplianceResult, error)

Panic Recovery Behavior:

Each plugin's RunChecks() call is wrapped in a defer recover() block to isolate plugin failures:

var (
    findings []compliance.Finding
    evaluated []string
    runErr error
    panicked bool
)

func() {
    defer func() {
        if r := recover(); r != nil {
            if logger.IsVerbose() {
                logger.Error("plugin panicked during RunChecks",
                    "plugin", pluginName,
                    "panic", r,
                    "stack", string(debug.Stack()),
                )
            } else {
                logger.Error("plugin panicked during RunChecks",
                    "plugin", pluginName,
                    "panic", r,
                )
            }
        }
    }()
    findings, evaluated, runErr = p.RunChecks(device)
}()

Key Invariants:

  • Panicked plugins are logged with panic details
  • Stack traces are only included when verbose logging is enabled (logger.IsVerbose()) to prevent function names and plugin paths from leaking into centralized logs at default verbosity
  • Panicked plugins are always retained in the result with zero findings—they appear in PluginFindings, PluginInfo, Compliance, and summary maps
  • Execution continues with remaining plugins, ensuring that one misbehaving plugin (especially dynamically-loaded) cannot crash the entire audit process
  • Downstream consumers can see that the plugin was requested and evaluated, even though it produced no findings

This design is particularly critical for dynamic plugins, which execute arbitrary third-party code without compile-time safety guarantees.

Plugin Manager#

The PluginManager orchestrates the plugin lifecycle and provides high-level operations:

type PluginManager struct {
    registry *PluginRegistry
    logger *logging.Logger
    pluginDir string
    explicitPluginDir bool
    loadResult LoadResult
}

Constructor:

The NewPluginManager constructor signature is:

func NewPluginManager(logger *logging.Logger, reg *PluginRegistry) *PluginManager

Parameters:

  • logger - Logger instance for diagnostics
  • reg - The *PluginRegistry this manager will read from and write to. Pass a shared registry when multiple managers or subsystems must observe the same plugin set (e.g., CLI helpers and the audit pipeline). Pass nil to allocate a fresh private registry—the appropriate default for short-lived programmatic callers.

This consolidates the previously independent registry paths: whichever *PluginRegistry is supplied here is the one InitializePlugins() populates and ListAvailablePlugins() / RunComplianceAudit() read from. The earlier split between the manager's internal registry and the global singleton (see GOTCHAS §2.1) has been resolved.

Key Operations:

  • InitializePlugins() - Registers all built-in compliance plugins (STIG, SANS, Firewall) and loads dynamic plugins if configured
  • SetPluginDir(dir string, explicit bool) - Configures the directory for dynamic plugin loading (must be called before InitializePlugins())
  • GetLoadResult() LoadResult - Retrieves the result of dynamic plugin loading after InitializePlugins() completes
  • RunComplianceAudit() - Executes compliance checks for selected plugins and aggregates results
  • ListAvailablePlugins() - Returns metadata about all registered plugins
  • GetPluginStatistics() - Provides usage statistics for plugin execution

Dynamic Plugin Loading Lifecycle:

The PluginManager coordinates dynamic plugin loading as part of the initialization sequence:

  1. Caller invokes SetPluginDir(dir, explicit) to configure the plugin directory
    • explicit=true indicates the path was explicitly set via CLI flag (missing directory is an error)
    • explicit=false indicates a default/optional path (missing directory is silently skipped)
  2. Caller invokes InitializePlugins(), which registers built-in plugins and then calls LoadDynamicPlugins() if a directory was configured
  3. Directory-level errors (missing explicit directory, unreadable directory) cause InitializePlugins() to return an error immediately
  4. Per-plugin load failures are non-fatal and do not cause InitializePlugins() to return an error
  5. After InitializePlugins() returns, caller invokes GetLoadResult() to retrieve load statistics and per-plugin failure details
  6. Caller is responsible for surfacing load failures to users (e.g., via warning logs)

Important Invariant:

Per-plugin dynamic load failures are non-fatal by design. The InitializePlugins() method documents this behavior explicitly:

Dynamic plugin loading: if SetPluginDir was called before this method,
dynamic .so plugins are loaded from the configured directory. Per-plugin
load failures are non-fatal — they do NOT cause InitializePlugins to return
an error. Callers must inspect GetLoadResult() after this method returns to
detect and surface dynamic plugin load failures.

This design ensures that partial plugin loading does not prevent audits from running with successfully loaded plugins.

Compliance Results#

ComplianceResults Structure#

The common.ComplianceResults struct (defined in pkg/model/enrichment.go) aggregates compliance audit results and is populated in the CommonDevice.ComplianceResults field:

type ComplianceResults struct {
    Mode string `json:"mode,omitempty"`
    Findings []ComplianceFinding `json:"findings,omitempty"`
    PluginResults map[string]PluginComplianceResult `json:"pluginResults,omitempty"`
    Summary *ComplianceResultSummary `json:"summary,omitempty"`
    Metadata map[string]any `json:"metadata,omitempty"`
}

Field Descriptions:

  • Mode - Audit report mode ("blue", "red")
  • Findings - Top-level security analysis findings (distinct from per-plugin findings)
  • PluginResults - Per-plugin compliance results keyed by plugin name
  • Summary - Aggregate summary across all plugins
  • Metadata - Arbitrary audit metadata

The ComplianceResults struct provides a HasData() method that reports whether the structure contains meaningful data, checking for non-zero values in Mode, Findings, PluginResults, Summary, or Metadata.

Nested Structures#

PluginComplianceResult: Contains compliance results for a single plugin:

type PluginComplianceResult struct {
    PluginInfo CompliancePluginInfo `json:"pluginInfo"`
    Findings []ComplianceFinding `json:"findings,omitempty"`
    Summary *ComplianceResultSummary `json:"summary,omitempty"`
    Controls []ComplianceControl `json:"controls,omitempty"`
    Compliance map[string]bool `json:"compliance,omitempty"`
}

ComplianceFinding: Individual finding with full metadata:

type ComplianceFinding struct {
    Type string `json:"type,omitempty"`
    Severity string `json:"severity,omitempty"`
    Title string `json:"title,omitempty"`
    Description string `json:"description,omitempty"`
    Recommendation string `json:"recommendation,omitempty"`
    Component string `json:"component,omitempty"`
    References []string `json:"references,omitempty"`
    Reference string `json:"reference,omitempty"`
    Tags []string `json:"tags,omitempty"`
    Metadata map[string]string `json:"metadata,omitempty"`
    AttackSurface *ComplianceAttackSurface `json:"attackSurface,omitempty"`
    ExploitNotes string `json:"exploitNotes,omitempty"`
    Control string `json:"control,omitempty"`
}

ComplianceResultSummary: Aggregate statistics with severity counts and compliance status:

type ComplianceResultSummary struct {
    TotalFindings int `json:"totalFindings"`
    CriticalFindings int `json:"criticalFindings"`
    HighFindings int `json:"highFindings"`
    MediumFindings int `json:"mediumFindings"`
    LowFindings int `json:"lowFindings"`
    InfoFindings int `json:"infoFindings"`
    PluginCount int `json:"pluginCount"`
    Compliant int `json:"compliant"`
    NonCompliant int `json:"nonCompliant"`
}

ComplianceControl: Control definition from a plugin:

type ComplianceControl struct {
    ID string `json:"id,omitempty"`
    Status string `json:"status"`
    Title string `json:"title,omitempty"`
    Description string `json:"description,omitempty"`
    Category string `json:"category,omitempty"`
    Severity string `json:"severity,omitempty"`
    Rationale string `json:"rationale,omitempty"`
    Remediation string `json:"remediation,omitempty"`
    References []string `json:"references,omitempty"`
    Tags []string `json:"tags,omitempty"`
    Metadata map[string]string `json:"metadata,omitempty"`
}

Field Descriptions:

  • Status - Compliance evaluation result ("PASS" or "FAIL"), derived from the Compliance map during export mapping. Constants ControlStatusPass and ControlStatusFail define the canonical status values.

CompliancePluginInfo: Plugin metadata:

type CompliancePluginInfo struct {
    Name string `json:"name,omitempty"`
    Version string `json:"version,omitempty"`
    Description string `json:"description,omitempty"`
}

ComplianceAttackSurface: Attack surface information for red team findings:

type ComplianceAttackSurface struct {
    Type string `json:"type,omitempty"`
    Ports []int `json:"ports,omitempty"`
    Services []string `json:"services,omitempty"`
    Vulnerabilities []string `json:"vulnerabilities,omitempty"`
}

Internal Audit Structures#

The audit package maintains its own ComplianceResult and ComplianceSummary structures for internal processing. The calculateSummary() method aggregates findings by severity and calculates per-plugin compliance statistics using a countSeverities() helper function that switches on finding.Severity (case-insensitive).

The countSeverities() function properly counts findings across all five standard severity levels (critical, high, medium, low, info) and tracks any unrecognized severity values in a separate unknown field. This ensures that informational findings are included in summary statistics rather than being silently dropped. When mode_controller.go detects plugins producing findings with unrecognized severity values (via counts.unknown > 0), it logs a warning with the plugin name and count.

The severity constants used in the audit engine are derived from the canonical analysis.Severity values:

  • severityCritical maps to string(analysis.SeverityCritical)
  • severityHigh maps to string(analysis.SeverityHigh)
  • severityMedium maps to string(analysis.SeverityMedium)
  • severityLow maps to string(analysis.SeverityLow)
  • severityInfo maps to string(analysis.SeverityInfo)

The audit engine provides computePerPluginSummary() to generate per-plugin compliance summaries with severity breakdowns, enabling blue team reports to display severity statistics for each plugin individually. The ComplianceSummary structure includes an InfoFindings field to track informational findings.

Inventory Controls and Compliance Maps:

Controls with Type: "inventory" are explicitly excluded from the evaluated slice and the compliance map. During the compliance status flip phase in RunComplianceChecks(), findings with Type: "inventory" are skipped to prevent accidental map pollution. Inventory findings represent informational configuration observations that do not affect pass/fail compliance status. In contrast, info-severity compliance findings (controls with Severity: "info" but no Type field or Type: "compliance") are included in the compliance map and participate in pass/fail evaluation—severity level never bypasses compliance flip logic.

Audit Report Rendering Architecture#

Audit report rendering is handled by the internal/converter/builder/ layer, not the cmd/audit_handler.go CLI layer. This separation enables multi-format output (markdown, JSON, YAML) without format-specific code in the command handler.

Rendering Flow:

  1. Audit Execution: handleAuditMode() in cmd/audit_handler.go runs compliance checks via the audit engine and receives an audit.Report
  2. Plugin Load Failure Handling: If GetLoadResult().Failed() > 0, the handler logs a warning with the list of failed plugin filenames
  3. Mapping: The handler calls mapAuditReportToComplianceResults() to convert the internal audit.Report structure to a common.ComplianceResults structure suitable for export
  4. Device Enrichment: The mapped results are attached to a shallow copy of the CommonDevice.ComplianceResults field, leaving the original device unchanged
  5. Options Threading: Audit-specific rendering options (e.g., FailuresOnly) are threaded into converter options before report generation
  6. Delegation: Report generation is delegated to generateWithProgrammaticGenerator(), which invokes the standard converter pipeline
  7. Format-Specific Rendering:
    • Markdown: The HybridGenerator calls SetFailuresOnly() to configure filtering behavior, then calls BuildAuditSection() on the ReportBuilder interface, which renders compliance summaries, plugin results tables with PASS/FAIL status columns, and metadata into markdown
    • JSON/YAML: The ComplianceChecks field is automatically serialized via struct tags—no custom rendering required

Key Components:

  • BuildAuditSection() - Added to the ReportBuilder interface in internal/converter/builder/builder.go; renders compliance data into markdown tables and summaries. When Controls data is available for a plugin, renders a unified controls table with a Status column (PASS/FAIL). When failuresOnly mode is enabled, only controls with Status="FAIL" are included in the table. Falls back to a findings-only table when Controls data is unavailable. Partitions findings by type: security/compliance findings are rendered in a "Security Findings" table, while inventory findings (Type: "inventory") are rendered separately in a "Configuration Notes" table with a different structure (Component, Title, Details).
  • SetFailuresOnly() - Added to the ReportComposer interface in internal/converter/builder/builder.go; configures whether only non-compliant controls are shown in audit reports. Called by HybridGenerator before BuildAuditSection().
  • mapAuditReportToComplianceResults() - Bridges audit.Report (internal representation) to common.ComplianceResults (export model). Calls mapControls() to enrich each control with its compliance status from the Compliance map.
  • EscapePipeForMarkdown() and TruncateString() - Helper functions in internal/converter/builder/helpers.go for safe markdown table rendering

This architecture ensures that all output formats (markdown, JSON, YAML) receive consistent compliance data through the standard export model without duplicating rendering logic in the CLI layer.

Built-in Plugin Implementations#

opnDossier ships with three built-in compliance plugins that implement common security frameworks:

Firewall Plugin#

The Firewall Plugin implements security controls and informational inventory checks focusing on basic firewall security hygiene and configuration documentation:

Security Controls (FIREWALL-001 through FIREWALL-061):

  • FIREWALL-001: SSH Warning Banner configuration
  • FIREWALL-002: Automatic Configuration Backup enabled
  • FIREWALL-003: Hostname Configuration presence (info severity)
  • FIREWALL-004: DNS Server Configuration
  • FIREWALL-005: IPv6 Disablement (when applicable)
  • FIREWALL-006: HTTPS Web Management interface
  • FIREWALL-007 through FIREWALL-061: Additional security configurations covering rule hygiene, logging, time synchronization, change management, and high availability

Inventory Controls (FIREWALL-062 through FIREWALL-063):

  • FIREWALL-062: DHCP Scope Inventory — Reports configured DHCP scopes, covering both ISC DHCP (legacy) and Kea DHCP4 (modern) scopes. The description separates the two sources: ISC scopes are labeled by interface name (e.g., "2 ISC DHCP scope(s) on: lan, guest"), Kea subnets are labeled by their description field or "(unnamed)" if empty (e.g., "1 Kea subnet(s): LAN subnet"). Both parts are joined with "; " when both types are present. The check calls device.HasDHCP(), which returns true when len(device.DHCP) > 0; both ISC and Kea scopes are normalized into the unified DHCP slice. (Type: "inventory", excluded from compliance map)
  • FIREWALL-063: Active Interface Summary — Reports enabled interfaces and their types (Type: "inventory", excluded from compliance map)

Several controls have been reclassified to info severity (FIREWALL-003, 026, 042, 044, 060) as they represent informational observations rather than security failures.

SANS Plugin#

The SANS Plugin implements the SANS Firewall Checklist controls (SANS-FW-001 through SANS-FW-004):

  • SANS-FW-001: Default Deny Policy verification
  • SANS-FW-002: Explicit Rule Configuration requirements
  • SANS-FW-003: Network Zone Separation enforcement
  • SANS-FW-004: Comprehensive Logging configuration

STIG Plugin#

The STIG Plugin implements DISA Firewall Security Requirements Guide controls (V-206694, V-206674, V-206690, V-206682) with sophisticated checks for overly permissive rules, unnecessary services, and logging configuration.

The STIG plugin uses a three-state checkResult pattern to prevent false positives:

  • Compliant (Known=true, Result=pass): Control requirements verified as satisfied
  • Non-Compliant (Known=true, Result=fail): Control requirements verified as not satisfied
  • Unknown (Known=false): Insufficient data to determine compliance status

This pattern ensures that missing configuration data does not trigger false non-compliance findings.

Finding Stamping Patterns#

Findings are linked to controls through the References field, which contains a list of control IDs. This establishes traceability from findings back to the specific compliance requirements they represent.

All built-in plugins (Firewall, SANS, STIG) follow a mandatory severity derivation pattern where findings populate the Severity field by calling a controlSeverity(id string) helper method. This helper looks up the control definition by ID and returns its Severity field, ensuring that severity values are derived from control metadata rather than hard-coded literals.

The Severity field should use the string representation of analysis.Severity constants (analysis.SeverityCritical, analysis.SeverityHigh, analysis.SeverityMedium, analysis.SeverityLow, analysis.SeverityInfo) as the canonical severity levels. For example:

findings = append(findings, compliance.Finding{
    Type: "compliance",
    Severity: sp.controlSeverity("STIG-V-206694"),
    Title: "Missing Default Deny Policy",
    // ... other fields
})

If a plugin returns a finding without a Severity value, the audit engine will invoke deriveSeverityFromControl() to automatically populate it from the referenced control metadata. If no valid control reference exists or the control lacks a severity value, the audit will return an error.

Plugin association is tracked separately via the PluginInfo field in PluginComplianceResult. The Finding struct does not include a Plugin field, contrary to what the background context suggests. Instead, the relationship between findings and plugins is maintained through the result aggregation structure.

Critical Implementation Patterns#

Nil Handling#

The plugin architecture includes several nil-safety patterns:

Slice Handling#

Findings are appended directly as returned by plugins without cloning. The background context mentions that findings should be cloned before mutation, but this pattern is not currently implemented in the codebase. Plugin developers should be aware that returned finding slices may be cached by the framework.

Error Handling#

A critical error handling issue exists with duplicate sentinel errors:

This creates potential bugs where error identity checks will fail when comparing errors from different sources.

CLI Integration#

The plugin architecture is exposed through two command interfaces: the dedicated opndossier audit command and the opndossier convert --audit-mode command for backward compatibility.

Dedicated Audit Command#

The opndossier audit command is the primary entry point for security audits and compliance checks. It is implemented in cmd/audit.go and provides a first-class interface for audit operations.

Available Flags:

  • --mode (default: blue) - Audit mode: blue, red
  • --plugins - Comma-separated list of compliance plugins to run (blue mode only)
  • --failures-only - Show only failing controls in blue mode plugin results tables (markdown format only)
  • --blackhat - Enable blackhat commentary for red team reports
  • --plugin-dir - Directory containing dynamic .so compliance plugins. Plugins run with full process privileges; signatures are not verified. See the Dynamic Plugin Security section below.
  • Shared output flags: --format, --output, --redact, --wrap, --section, --comprehensive

Usage Examples:

# Blue team audit with all plugins (default)
opndossier audit config.xml

# Blue team audit with specific plugins
opndossier audit config.xml --plugins stig,sans

# Red team audit with blackhat commentary
opndossier audit config.xml --mode red --blackhat

# Custom plugin directory
opndossier audit config.xml --plugin-dir /opt/plugins

# Multi-file audit
opndossier audit config1.xml config2.xml

Key Behavioral Constraints:

  • --plugins flag is only valid with --mode blue (rejected for red mode)
  • --failures-only flag is only valid with --mode blue and --format markdown (rejected for other modes/formats)
  • Bare --mode blue (without --plugins) runs all available plugins by default
  • --output flag is rejected with multiple input files (per-input auto-naming is used instead)
  • Multi-file audits produce uniquely named reports using path-based derivation to prevent collisions

Multi-File Output Naming:

When auditing multiple files, each report is auto-named based on the input path with an -audit suffix and the appropriate format extension. Directory components are encoded using tilde-based escaping to prevent filename collisions between files that share the same basename:

config.xml -> config-audit.md
prod/site-a/config.xml -> prod_site-a_config-audit.md
dr/site-a/config.xml -> dr_site-a_config-audit.md

The path encoding uses lossless tilde-based escaping where tildes become ~~ and underscores become ~u, allowing underscore to serve as an unambiguous segment separator in flattened filenames.

Dynamic Plugin Loading#

Both command interfaces support dynamic plugin loading via the --plugin-dir flag, which specifies the directory containing .so compliance plugins.

Stderr Warning:

Whenever --plugin-dir is supplied with a non-empty value, a warning is emitted to stderr before any plugins are loaded:

Warning: --plugin-dir loads dynamic .so plugins with full process privileges
and no signature verification. Only load plugins you trust and have reviewed.
See GOTCHAS §2.5 for details.

This warning is intentionally shown at opt-in time to surface the trust model to users before plugin loading begins, similar to the red-mode experimental warning.

Integration Flow:

  1. The CLI parses --plugin-dir into a command-specific flag variable
  2. buildAuditOptions() constructs an audit.Options value with:
    • PluginDir set to the flag value
    • ExplicitPluginDir=true (indicating the user explicitly configured this path)
  3. handleAuditMode() in cmd/audit_handler.go calls pm.SetPluginDir(opts.PluginDir, opts.ExplicitPluginDir) before InitializePlugins()
  4. After InitializePlugins() completes, the handler calls pm.GetLoadResult() to retrieve load statistics
  5. If any plugins failed to load, the handler logs a warning with the count and filenames:
WARN Some dynamic plugins failed to load
  failed=2
  loaded=3
  files="custom1.so, custom2.so"

This ensures that users are notified of load failures through visible CLI output, not just log files. Per-plugin errors can be inspected via the LoadResult.Failures slice if programmatic access is needed.

Backward Compatibility#

The existing convert --audit-mode workflow remains available for backward compatibility but is now considered secondary to the dedicated audit command. Both use the same underlying plugin architecture and produce identical output. See the Audit Command Reference for complete flag documentation.

Dynamic Plugin Security#

The dynamic plugin loading mechanism operates on a trust model with specific security trade-offs. For complete details on the trust model, threat surface, preflight checks, and operational guidance, see:

Known Issues#

Duplicate Sentinel Error Definitions#

A critical error handling issue exists with duplicate sentinel errors:

This creates potential bugs where error identity checks will fail when comparing errors from different sources.

Usage and Examples#

Creating a Static Plugin#

To create a static plugin, create a new directory in internal/plugins/ and implement the compliance.Plugin interface:

package custom

import (
    "github.com/EvilBit-Labs/opnDossier/internal/compliance"
    common "github.com/EvilBit-Labs/opnDossier/pkg/model"
)

type CustomPlugin struct {
    controls []compliance.Control
}

func NewCustomPlugin() *CustomPlugin {
    return &CustomPlugin{
        controls: []compliance.Control{
            {
                ID: "CUSTOM-001",
                Title: "Custom Security Check",
                Description: "Verifies custom security requirement",
                Category: "security",
                Severity: "high",
                Rationale: "This control ensures...",
                Remediation: "To remediate, configure...",
            },
        },
    }
}

func (p *CustomPlugin) Name() string { return "custom" }
func (p *CustomPlugin) Version() string { return "1.0.0" }
func (p *CustomPlugin) Description() string { return "Custom compliance checks" }

func (p *CustomPlugin) RunChecks(device *common.CommonDevice) ([]compliance.Finding, []string, error) {
    var findings []compliance.Finding
    var evaluated []string

    // Perform checks and append findings
    if !isCompliant(device) {
        findings = append(findings, compliance.Finding{
            Type: "compliance",
            Severity: p.controlSeverity("CUSTOM-001"),
            Title: "Non-compliant configuration detected",
            Description: "The device configuration does not meet CUSTOM-001",
            Recommendation: "Update configuration to meet requirements",
            References: []string{"CUSTOM-001"},
        })
    }

    // Mark this control as evaluated (regardless of pass/fail)
    evaluated = append(evaluated, "CUSTOM-001")

    return findings, evaluated, nil
}

func (p *CustomPlugin) GetControls() []compliance.Control {
    return compliance.CloneControls(p.controls)
}

func (p *CustomPlugin) GetControlByID(id string) (*compliance.Control, error) {
    for i := range p.controls {
        if p.controls[i].ID == id {
            return &p.controls[i], nil
        }
    }
    return nil, compliance.ErrControlNotFound
}

func (p *CustomPlugin) ValidateConfiguration() error {
    return nil // Validate any configuration requirements
}

func (p *CustomPlugin) controlSeverity(id string) string {
    for _, c := range p.controls {
        if c.ID == id {
            return c.Severity
        }
    }
    return ""
}

Best Practices:

Creating a Dynamic Plugin#

Dynamic plugins must be built with -buildmode=plugin to produce .so files:

package main

import (
    "github.com/EvilBit-Labs/opnDossier/internal/compliance"
    "github.com/EvilBit-Labs/opnDossier/internal/plugins/custom"
)

// Plugin is the exported symbol that the dynamic loader looks for
var Plugin compliance.Plugin = custom.NewCustomPlugin()

Build Command:

go build -buildmode=plugin -o custom.so custom_plugin.go

Important Constraints:

Migrating to CommonDevice API#

The RunChecks() method signature changed from *model.OpnSenseDocument to *common.CommonDevice in recent versions. As of v1.3.0, the CommonDevice model and related types are now public API in pkg/model and can be imported by external plugin implementations.

Migration Steps:

  1. Update imports from "github.com/EvilBit-Labs/opnDossier/internal/model" to common "github.com/EvilBit-Labs/opnDossier/pkg/model"
  2. Change method signature: RunChecks(config *model.OpnSenseDocument)RunChecks(device *common.CommonDevice)
  3. Update field access as CommonDevice uses Go naming conventions rather than XML tag names

Architectural Evaluation#

Strengths#

The plugin architecture demonstrates several best-practice design patterns:

Areas for Improvement#

Several architectural decisions limit testability and robustness:

Relevant Code Files#

File PathPurposeKey Features
internal/analysis/finding.goCanonical finding typesDefines canonical Finding struct and Severity type with validation helpers
internal/compliance/interfaces.goPlugin interface contractDefines Plugin interface, Control struct, and Finding type alias to analysis.Finding
internal/compliance/errors.goError definitionsDefines ErrPluginNotFound sentinel error
internal/audit/plugin.goPlugin registryThread-safe registration, dynamic loading, compliance check execution
internal/audit/plugin_manager.goPlugin lifecycle managementInitialization, audit execution, statistics
internal/audit/mode_controller.goAudit mode controlContains duplicate ErrPluginNotFound definition; audit.Finding embeds analysis.Finding
internal/converter/builder/builder.goReport builderImplements BuildAuditSection() to render ComplianceResults into markdown tables and summaries
cmd/audit.goAudit commandDedicated audit command with flag definitions, PreRunE validation, multi-file processing
cmd/audit_output.goAudit outputPer-input path derivation (deriveAuditOutputPath), tilde-based escaping, result emission
cmd/audit_handler.goAudit CLI handlerMaps audit.Report to common.ComplianceResults via mapAuditReportToComplianceResults() and delegates rendering to converter layer
cmd/shared_flags.goShared flag validationvalidateOutputFlags() shared between audit and convert commands
internal/plugins/firewall/Firewall plugin implementation8 basic firewall security controls
internal/plugins/sans/SANS plugin implementationSANS Firewall Checklist controls (FW-001 through FW-004)
internal/plugins/stig/STIG plugin implementationDISA STIG controls with three-state check pattern
  • Compliance Standards Integration - How opnDossier maps security frameworks (STIG, SANS, CIS) to plugin implementations
  • OPNsense Configuration Parsing - The parsing layer that transforms XML configurations into the CommonDevice model
  • CommonDevice Data Model - The unified device representation that plugins operate on
  • Audit Report Generation - How compliance results are formatted and exported for consumption; includes the converter/builder layer that renders ComplianceResults into markdown, JSON, and YAML formats
  • Thread-Safe Design Patterns - Use of sync.RWMutex and concurrent access patterns in Go

See Also#

Audit Plugin Architecture | Dosu