Plugin Development Guide¶
Overview¶
opnDossier uses a plugin-based architecture for compliance standards, allowing developers to create custom compliance plugins that integrate seamlessly with the core audit engine. Plugins can be either statically registered (baked into the binary) or dynamically loaded at runtime as Go plugins (.so files). This guide explains how to create, implement, and integrate new compliance plugins.
Plugin Architecture¶
Core Components¶
compliance.PluginInterface: Defines the contract that all plugins must implementPluginRegistry: Manages plugin registration, dynamic loading, and lifecyclePluginManager: Coordinates plugin operations and provides high-level APIsControlStruct: Represents individual compliance controls within a standard
Plugin Interface¶
All plugins must implement the compliance.Plugin interface:
import (
"github.com/EvilBit-Labs/opnDossier/internal/compliance"
common "github.com/EvilBit-Labs/opnDossier/pkg/model"
)
type Plugin interface {
Name() string // Unique plugin identifier
Version() string // Plugin version
Description() string // Human-readable description
RunChecks(device *common.CommonDevice) []compliance.Finding // Execute compliance checks
GetControls() []compliance.Control // Return all controls
GetControlByID(id string) (*compliance.Control, error) // Get specific control
ValidateConfiguration() error // Validate plugin config
}
The Finding struct is generic and uses Severity, References, Tags, and Metadata fields:
// compliance.Finding
Type string // e.g. "compliance"
Severity string // e.g. "high" — copied from control's severity
Title string
Description string
Recommendation string
Component string
Reference string
References []string // Control IDs or external references
Tags []string // Arbitrary tags for filtering/categorization
Metadata map[string]string // Optional extra data
Creating a New Plugin¶
Step 1: Plugin Structure¶
For static plugins, create a new directory in internal/plugins/:
internal/plugins/
├── stig/
│ └── stig.go
├── sans/
│ └── sans.go
├── firewall/
│ └── firewall.go
└── your_plugin/
└── your_plugin.go
For dynamic plugins, create a new Go module or directory with a main package.
Step 2: Plugin Implementation¶
Static Plugin Example¶
package plugins
import (
"fmt"
"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 Control",
Description: "Description of the custom security control",
Category: "Security",
Severity: "high",
Rationale: "Why this control is important",
Remediation: "How to fix compliance issues",
Tags: []string{"custom", "security", "compliance"},
},
},
}
}
func (cp *CustomPlugin) Name() string { return "custom" }
func (cp *CustomPlugin) Version() string { return "1.0.0" }
func (cp *CustomPlugin) Description() string { return "Custom compliance checks for specific security requirements" }
func (cp *CustomPlugin) GetControls() []compliance.Control { return cp.controls }
func (cp *CustomPlugin) GetControlByID(id string) (*compliance.Control, error) {
for _, control := range cp.controls {
if control.ID == id {
return &control, nil
}
}
return nil, fmt.Errorf("control '%s' not found", id)
}
func (cp *CustomPlugin) ValidateConfiguration() error {
if len(cp.controls) == 0 {
return fmt.Errorf("no controls defined")
}
return nil
}
func (cp *CustomPlugin) RunChecks(device *common.CommonDevice) []compliance.Finding {
var findings []compliance.Finding
// Implement your compliance checks here
// Example:
findings = append(findings, compliance.Finding{
Type: "compliance",
Severity: "high",
Title: "Missing Custom Security Feature",
Description: "The configuration is missing required custom security feature",
Recommendation: "Enable the custom security feature in the configuration",
Component: "security",
Reference: "CUSTOM-001",
References: []string{"CUSTOM-001"},
Tags: []string{"custom", "security", "compliance"},
})
return findings
}
Dynamic Plugin Example¶
package main
import (
"github.com/EvilBit-Labs/opnDossier/internal/compliance"
common "github.com/EvilBit-Labs/opnDossier/pkg/model"
)
type MyDynamicPlugin struct{}
// Implement compliance.Plugin methods...
// RunChecks(device *common.CommonDevice) []compliance.Finding
var Plugin compliance.Plugin = &MyDynamicPlugin{}
Build with:
Step 3: Plugin Registration¶
- Static plugins: Register in the plugin manager as before.
- Dynamic plugins: Drop
.sofiles into the plugin directory (default:./plugins). They will be loaded automatically at startup.
Dynamic Plugin Loading¶
- The audit engine will scan a configurable directory for
.sofiles and load any plugin that exportsvar Plugin compliance.Plugin. - Dynamic plugins must be built with the same Go version and dependencies as the main binary.
- Both static and dynamic plugins are supported and can coexist.
RunComplianceCheckswraps each plugin'sRunChecks()indefer recover()so a panicking dynamic plugin cannot crash the audit. See panic recovery solution and GOTCHAS.md SS2.2.
Migrating to the CommonDevice Plugin API¶
Breaking change (internal API — semver stays v1.x): The RunChecks method signature changed from *model.OpnSenseDocument to *common.CommonDevice.
| Item | v1.x | Current |
|---|---|---|
| Import | internal/model |
pkg/model |
| Parameter | *model.OpnSenseDocument |
*common.CommonDevice |
Migration steps:
- Replace
"github.com/EvilBit-Labs/opnDossier/internal/model"import withcommon "github.com/EvilBit-Labs/opnDossier/pkg/model" - Change
RunChecks(config *model.OpnSenseDocument)toRunChecks(device *common.CommonDevice) - Update field access —
CommonDevicemirrors the full OPNsense surface area; field names follow Go domain conventions rather than XML tag names. Refer topkg/model/for the full type definitions.
Plugin Development Best Practices¶
- Use unique, descriptive control IDs and titles.
- Provide actionable remediation and clear rationale.
- Always set
Finding.Severityto match the control'sSeverityfor correct severity breakdown in audit reports. - Use the
ReferencesandTagsfields for all findings. - Write comprehensive tests for your plugin.
- Document your controls and plugin usage.
Device Parser Development¶
opnDossier supports adding new device types (e.g., pfSense, Fortinet, MikroTik) through a compile-time parser registry. This is separate from compliance plugins -- device parsers transform vendor-specific configuration files into the platform-agnostic CommonDevice model.
Architecture¶
The DeviceParserRegistry in pkg/parser/registry.go follows the database/sql driver registration pattern:
- Parsers self-register via
init()functions - The
Factoryauto-detects device type from the XML root element - External parsers link at compile time via blank imports
Creating a Device Parser¶
- Create a Go package that implements the
parser.DeviceParserinterface:
package pfsense
import (
"context"
"io"
common "github.com/EvilBit-Labs/opnDossier/pkg/model"
"github.com/EvilBit-Labs/opnDossier/pkg/parser"
)
type PfSenseParser struct{}
func (p *PfSenseParser) Parse(
ctx context.Context, r io.Reader,
) (*common.CommonDevice, []common.ConversionWarning, error) {
// Parse pfSense XML and convert to CommonDevice
}
func (p *PfSenseParser) ParseAndValidate(
ctx context.Context, r io.Reader,
) (*common.CommonDevice, []common.ConversionWarning, error) {
// Parse + validate
}
- Register via
init():
func init() {
parser.Register("pfsense", func(dec parser.XMLDecoder) parser.DeviceParser {
return &PfSenseParser{}
})
}
The first argument ("pfsense") must match the XML root element name of the config file.
- Link via blank import in your consumer binary:
package main
import (
"github.com/EvilBit-Labs/opnDossier/cmd"
_ "github.com/example/pfsense-parser" // self-registers at init()
)
func main() { cmd.Execute() }
Key Types¶
| Type | Description |
|---|---|
parser.DeviceParser |
Interface: Parse() and ParseAndValidate() returning (*CommonDevice, []ConversionWarning, error) |
parser.ConstructorFunc |
Factory signature: func(XMLDecoder) DeviceParser |
parser.XMLDecoder |
XML parsing backend injected by the Factory; external parsers that handle their own XML may ignore it |
Registration Rules¶
- Device type names are normalized to lowercase with whitespace trimmed
- Duplicate registrations panic at startup (fail-fast)
- Nil factories and empty names panic at startup
parser.DefaultRegistry().List()returns all registered types (sorted)
Testing¶
Use parser.NewFactoryWithRegistry() with parser.NewDeviceParserRegistry() for isolated tests that don't pollute the global registry:
reg := parser.NewDeviceParserRegistry()
reg.Register("testdevice", myFactory)
factory := parser.NewFactoryWithRegistry(decoder, reg)
device, warnings, err := factory.CreateDevice(ctx, reader, "", false)
Common Pitfalls¶
Empty registry (missing blank import): The most common mistake is forgetting the blank import. Without it, your parser's init() never runs and the registry stays empty. The symptom is an error like:
unsupported device type: root element <pfsense> is not recognized; supported: (none registered -- ensure parser packages are imported)
Fix: add _ "your/parser/package" to the binary's import list.
Root element mismatch: The string passed to parser.Register() must exactly match the XML root element name (lowercase). If a pfSense config uses <pfsense> as the root element, register as "pfsense", not "pfSense" or "PfSense" (the registry normalizes to lowercase, but the XML root element detection also lowercases).
Duplicate registration: If two packages register the same root element name, the binary will panic at startup. This is intentional -- it surfaces conflicts immediately rather than silently picking one.
Source Files¶
pkg/parser/registry.go-- Registry implementationpkg/parser/factory.go-- Factory with auto-detection and error handlingpkg/parser/opnsense/parser.go-- Built-in OPNsense parser (reference implementation)
Troubleshooting¶
Compliance Plugins¶
- Plugin not loaded? Ensure it is built as a Go plugin (
-buildmode=plugin), exportsvar Plugin, and is in the correct directory. - Go version mismatch? All plugins and the main binary must be built with the exact same Go version and dependencies.
- Platform support: Go plugins are supported on Linux and macOS, not Windows.
Device Parsers¶
- Device type not recognized? Ensure the parser package is imported via blank import (
_ "pkg/path") in the binary soinit()runs. See "Common Pitfalls" above. - Panic on startup? Two packages registered the same root element name. Check for duplicate
parser.Register()calls. - Auto-detection picks wrong parser? Use
--device-typeto force a specific parser and bypass root element detection.
Examples¶
internal/plugins/contains static compliance plugin examples.pkg/parser/opnsense/parser.goprovides a reference device parser implementation.- The dynamic plugin example above demonstrates external compliance plugins.
Conclusion¶
The opnDossier plugin system is flexible: you can extend compliance coverage by adding new compliance plugins, and add new device types by registering device parsers via the DeviceParserRegistry. Both systems use self-registration patterns for zero-change extensibility.