15 KiB
Hydrogen Custom Security Modules
This document explains how to add your own security checking modules to Hydrogen. It is based on the actual module loading flow implemented in core/module_loader.py, core/base.py, core/schemas/modules.py, and the example SSH module under modules/ssh.
How Hydrogen Loads Modules
Hydrogen starts module execution in core/runner.py:
loaded_modules, module_discovery_errors = load_module_descriptors(modules_path, config, package_prefix)
The module loader then discovers modules from three sources (combined and deduplicated):
- Filesystem directory — scanned via
--path(default:modules/). plugin_packages.modules— Python packages listed inconfig.yaml.hydrogen.modulesentry points — registered by installed Python packages.
def discover_modules(path: Path, config: Config, package_prefix: str = "modules") -> list[str]:
# 1. Scan filesystem
for entry in path.iterdir():
if entry.is_dir() and not entry.name.startswith("__") and (entry / "__init__.py").exists():
package_names.append(f"{package_prefix}.{entry.name}")
# 2. From config
package_names.extend(config.plugin_packages.modules)
# 3. From entry points
for ep in entry_points(group="hydrogen.modules"):
package_names.append(ep.value.partition(":")[0])
return list(dict.fromkeys(package_names)) # deduplicate
Filesystem rules:
- Hydrogen only inspects direct children of the modules directory.
- Entries must be directories.
- Directory names starting with
__are skipped. - The directory must contain
__init__.py. - The package is imported as
<package_prefix>.<directory_name>.
Hydrogen does not recursively discover module packages inside nested directories. A module must be a first-level package under the selected modules root.
Required Module Contract
Each Hydrogen module package must provide three things:
MANIFEST— aModuleManifestinstance.build_worker(config)— a callable that returns aBaseWorker.CONFIG_MODEL(optional, recommended) — a PydanticBaseModelsubclass for config validation.
If either MANIFEST or build_worker is missing, the loader logs a warning and skips the module.
MANIFEST
MANIFEST is validated against core.schemas.manifest.ModuleManifest.
class ModuleManifest(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
identifier: str = Field(pattern=r"^[a-z][a-z0-9_-]*$")
name: str = Field(min_length=1)
category: str = Field(min_length=1)
version: str = Field(pattern=r"^\d+\.\d+\.\d+$")
api_version: str = Field("1", pattern=r"^\d+$")
description: str = ""
Required fields:
identifier— must match^[a-z][a-z0-9_-]*$name— human-readable name, non-emptycategory— group name used for exclusion, non-emptyversion— semantic versionX.Y.Zapi_version— must match Hydrogen'sHYDROGEN_API_VERSION(currently"1")
description is optional and defaults to an empty string.
Example from the built-in SSH module:
MANIFEST = ModuleManifest(
identifier="ssh",
name="SSH Security Audit",
category="ssh",
version="0.1.0",
api_version="1",
description="Audits OpenSSH server configuration.",
)
Important: api_version must match Hydrogen's API version. If it does not match, the module is rejected with a ValueError.
CONFIG_MODEL
Modules can export a CONFIG_MODEL — a Pydantic BaseModel subclass that defines the expected shape of the module's configuration section from config.yaml.
# modules/my_check/config.py
from pydantic import BaseModel, Field
class MyCheckConfig(BaseModel):
enabled: bool = Field(True)
threshold: int = Field(5, ge=1)
# modules/my_check/__init__.py
from .config import MyCheckConfig
CONFIG_MODEL = MyCheckConfig
When CONFIG_MODEL is defined, Hydrogen validates the module's config section from YAML against this model before passing it to build_worker():
module_config = config.modules.get(module.manifest.identifier, {})
validated_config = module.config_model.model_validate(module_config)
worker = build_worker(module, validated_config)
If CONFIG_MODEL is not defined, Hydrogen passes an EmptyPluginConfig instance (an empty Pydantic model).
build_worker(config)
The loader expects a callable named build_worker.
Signature:
def build_worker(config: MyCheckConfig) -> BaseWorker:
...
Rules:
- It must be callable.
- It receives the validated module-specific config (a
BaseModelinstance fromCONFIG_MODEL). - It must return an instance of
BaseWorker.
If it returns anything else, Hydrogen logs a warning and skips the module.
Worker Contract
All custom workers must inherit core.base.BaseWorker.
class BaseWorker(ABC):
config_model: type[BaseModel] = EmptyPluginConfig
def __init__(self, config: BaseModel | dict[str, Any] | None = None) -> None:
self.raw_config = config
self.config: BaseModel | dict[str, Any]
if isinstance(config, BaseModel):
self.config = config.model_dump(mode="python")
else:
self.config = config or {}
@abstractmethod
def run(self) -> AuditResults:
...
Key points:
__init__accepts either aBaseModel(preferred) or a rawdict.- The config is stored as both
self.raw_config(original form) andself.config(dict form for easy access). config_modelis a class attribute that can be set on the worker for reference.run()must returnAuditResults.
The built-in SSH module shows the recommended pattern:
class SSHSecurityWorker(BaseWorker):
config_model = SSHModuleConfig
def __init__(self, config: SSHModuleConfig | None = None) -> None:
super().__init__(config)
self.module_config = config or SSHModuleConfig()
def run(self) -> AuditResults:
if not self.module_config.enabled:
return AuditResults(status=AuditStatus.SKIPPED, findings=[], risk_level=0.0)
...
Required Result Shape
Your worker's run() method must return core.schemas.results.AuditResults.
class AuditResults(BaseModel):
status: AuditStatus
findings: list[AuditFindings]
risk_level: float
status
Allowed values from AuditStatus:
pass— the audit ran and found no issues.fail— the audit ran and produced one or more findings.skipped— the audit was intentionally not applicable on the current system.
findings
Each finding is an AuditFindings object:
class AuditFindings(BaseModel):
name: str
description: str = ""
severity: AuditSeverity
Severity values:
lowmediumhighcritical
These severities feed directly into Hydrogen's exit code calculation.
risk_level
risk_level is a float from 0.0 to 1.0 indicating how dangerous the results are.
Practical guidance:
- Return
0.0for clean results. - Return a bounded value up to
1.0. - Keep the calculation deterministic so reports remain comparable over time.
Full Module Layout
Here is the full structure Hydrogen expects:
modules/
my_check/
__init__.py
config.py # optional, recommended
worker.py
modules/my_check/config.py
from pydantic import BaseModel, Field
class MyCheckConfig(BaseModel):
enabled: bool = Field(True)
must_be_enabled: bool = Field(False)
modules/my_check/__init__.py
from core.base import BaseWorker
from core.schemas import ModuleManifest
from .config import MyCheckConfig
from .worker import MyCheckWorker
MANIFEST = ModuleManifest(
identifier="my_check",
name="My Custom Check",
category="custom",
version="1.0.0",
api_version="1",
description="Checks a custom hardening rule.",
)
CONFIG_MODEL = MyCheckConfig
def build_worker(config: MyCheckConfig) -> BaseWorker:
return MyCheckWorker(config)
modules/my_check/worker.py
from core.base import BaseWorker
from core.schemas.results import AuditFindings, AuditResults
from core.schemas.status import AuditSeverity, AuditStatus
from .config import MyCheckConfig
class MyCheckWorker(BaseWorker):
config_model = MyCheckConfig
def __init__(self, config: MyCheckConfig | None = None) -> None:
super().__init__(config)
self.module_config = config or MyCheckConfig()
def run(self) -> AuditResults:
findings: list[AuditFindings] = []
if self.module_config.must_be_enabled is not True:
findings.append(
AuditFindings(
name="custom_rule_disabled",
description="The custom rule is not enabled.",
severity=AuditSeverity.HIGH,
)
)
return AuditResults(
status=AuditStatus.PASS if not findings else AuditStatus.FAIL,
findings=findings,
risk_level=0.0 if not findings else 0.3,
)
Wiring Module Configuration
Hydrogen validates and passes module config by manifest identifier.
Given this manifest:
MANIFEST = ModuleManifest(identifier="my_check", ...)
The YAML section must be:
modules:
my_check:
must_be_enabled: true
Hydrogen does this:
module_config = config.modules.get(module.manifest.identifier, {})
validated_config = module.config_model.model_validate(module_config)
worker = build_worker(module, validated_config)
Important details:
- The key is
my_check, not the directory label shown to users in a report title. - If the key is missing, your module receives an empty dict, which is validated against your
CONFIG_MODEL(using defaults). - Config validation is strict: if you define
CONFIG_MODEL, extra fields in YAML that are not in the model will raise a validation error (unlessextra="allow"is set). - If config validation fails, Hydrogen reports a
PluginRuntimeErrorand continues with other modules — your worker is not created.
Category-Based Exclusion
Hydrogen can disable modules by manifest category.
Example:
exclude_categories:
- custom
If your module's manifest has category="custom", Hydrogen skips it before worker creation.
This is useful for:
- Experimental checks.
- Platform-specific checks.
- Expensive checks that should be disabled in some environments.
Module Discovery via Entry Points
Third-party modules can register themselves via Python entry points in the hydrogen.modules group.
Example pyproject.toml:
[project.entry-points."hydrogen.modules"]
my_check = "mycompany.hydrogen_modules.my_check"
The entry point value must be a Python package path (module or package). Hydrogen imports it and looks for MANIFEST and build_worker inside.
Import Mechanics and Packaging Rules
Hydrogen imports modules using Python package names, not only filesystem paths.
For a module directory named my_check and default package prefix modules, Hydrogen imports:
import modules.my_check
Therefore:
- The module directory must be importable as a Python package.
- Its
__init__.pyis the integration entry point. - Import-time exceptions are not swallowed — they propagate and fail the module.
For third-party packages installed via pip, set plugin_packages.modules in config:
plugin_packages:
modules:
- mycompany.hydrogen_modules
Or use entry points as described above.
Behavior on Loader Errors
Hydrogen is tolerant of contract violations, but only up to a point.
Handled by warning and skip:
- Missing
MANIFEST. - Missing
build_worker. - Non-callable
build_worker. build_worker()returning something that is not aBaseWorker.
Handled by error reporting (module skipped, run continues):
- Import failures while importing the package (caught in
load_modules()). - Config validation errors in
resolve_config(). - Runtime exceptions inside
worker.run()(caught in_run_single_worker()).
Not handled silently (can fail the entire run):
- Manifest validation failures (e.g.,
api_versionmismatch). - Missing
CONFIG_MODELexport with invalid type.
Duplicate Module Detection
Hydrogen detects duplicate module identifiers during config resolution:
if module.manifest.identifier in seen_module_ids:
runtime_errors.append(
PluginRuntimeError(
plugin_kind="module",
plugin_name=module.manifest.identifier,
stage="discovery",
message="duplicate module identifier",
)
)
continue
If two sources provide the same module identifier, the second one is rejected with an error.
Recommended Development Pattern
For custom Hydrogen modules, the safest pattern is:
- Define a
CONFIG_MODELPydantic model for type-safe config validation. - Keep
__init__.pysmall — justMANIFEST,CONFIG_MODEL, andbuild_worker(). - Put most logic in a separate worker module.
- Set
config_modelon your worker class for consistency. - Use stable, machine-friendly finding names.
- Return
skippedwhen the check is not applicable on the current platform. - Use
api_version="1"(match Hydrogen'sHYDROGEN_API_VERSION).
Example: Running a Custom Module Tree
If your module packages live outside the default modules/ directory, you must align the filesystem path and package prefix.
Example:
python main.py --path custom_checks --package-prefix custom_checks
Then Hydrogen expects packages like:
custom_checks/
__init__.py
my_check/
__init__.py
config.py
worker.py
Alternatively, add your package to plugin_packages.modules:
plugin_packages:
modules:
- custom_checks.my_check
Testing Checklist for a New Module
Before relying on a new Hydrogen module, verify all of the following:
- The package imports cleanly.
MANIFESTvalidates successfully (especiallyapi_version).CONFIG_MODEL(if defined) validates correctly.build_worker()returns a realBaseWorkerinstance.run()always returnsAuditResults.- All findings use valid
AuditSeverityvalues. - The module behaves correctly when its config section is missing.
- The module behaves correctly when it is excluded by category.
- The module handles runtime errors gracefully (Hydrogen wraps exceptions).
Summary
Hydrogen custom security modules are intentionally simple:
- One package per module.
- One manifest with
api_versionmatching Hydrogen's. - Optional
CONFIG_MODELfor validated config. - One worker builder receiving a validated config model.
- One worker object that returns structured results.
The most important implementation details are that module discovery supports three sources (filesystem, config packages, entry points), module config is validated by CONFIG_MODEL and keyed by MANIFEST.identifier, and import-time failures are reported but do not stop the entire run.