521 lines
15 KiB
Markdown
521 lines
15 KiB
Markdown
# 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`:
|
|
|
|
```python
|
|
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):
|
|
|
|
1. **Filesystem directory** — scanned via `--path` (default: `modules/`).
|
|
2. **`plugin_packages.modules`** — Python packages listed in `config.yaml`.
|
|
3. **`hydrogen.modules` entry points** — registered by installed Python packages.
|
|
|
|
```python
|
|
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` — a `ModuleManifest` instance.
|
|
- `build_worker(config)` — a callable that returns a `BaseWorker`.
|
|
- `CONFIG_MODEL` (optional, recommended) — a Pydantic `BaseModel` subclass 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`.
|
|
|
|
```python
|
|
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-empty
|
|
- `category` — group name used for exclusion, non-empty
|
|
- `version` — semantic version `X.Y.Z`
|
|
- `api_version` — must match Hydrogen's `HYDROGEN_API_VERSION` (currently `"1"`)
|
|
|
|
`description` is optional and defaults to an empty string.
|
|
|
|
Example from the built-in SSH module:
|
|
|
|
```python
|
|
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`.
|
|
|
|
```python
|
|
# modules/my_check/config.py
|
|
from pydantic import BaseModel, Field
|
|
|
|
class MyCheckConfig(BaseModel):
|
|
enabled: bool = Field(True)
|
|
threshold: int = Field(5, ge=1)
|
|
```
|
|
|
|
```python
|
|
# 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()`:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
def build_worker(config: MyCheckConfig) -> BaseWorker:
|
|
...
|
|
```
|
|
|
|
Rules:
|
|
|
|
- It must be callable.
|
|
- It receives the **validated** module-specific config (a `BaseModel` instance from `CONFIG_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`.
|
|
|
|
```python
|
|
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 a `BaseModel` (preferred) or a raw `dict`.
|
|
- The config is stored as both `self.raw_config` (original form) and `self.config` (dict form for easy access).
|
|
- `config_model` is a class attribute that can be set on the worker for reference.
|
|
- `run()` must return `AuditResults`.
|
|
|
|
The built-in SSH module shows the recommended pattern:
|
|
|
|
```python
|
|
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`.
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
class AuditFindings(BaseModel):
|
|
name: str
|
|
description: str = ""
|
|
severity: AuditSeverity
|
|
```
|
|
|
|
Severity values:
|
|
|
|
- `low`
|
|
- `medium`
|
|
- `high`
|
|
- `critical`
|
|
|
|
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.0` for 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:
|
|
|
|
```text
|
|
modules/
|
|
my_check/
|
|
__init__.py
|
|
config.py # optional, recommended
|
|
worker.py
|
|
```
|
|
|
|
### `modules/my_check/config.py`
|
|
|
|
```python
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
class MyCheckConfig(BaseModel):
|
|
enabled: bool = Field(True)
|
|
must_be_enabled: bool = Field(False)
|
|
```
|
|
|
|
### `modules/my_check/__init__.py`
|
|
|
|
```python
|
|
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`
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
MANIFEST = ModuleManifest(identifier="my_check", ...)
|
|
```
|
|
|
|
The YAML section must be:
|
|
|
|
```yaml
|
|
modules:
|
|
my_check:
|
|
must_be_enabled: true
|
|
```
|
|
|
|
Hydrogen does this:
|
|
|
|
```python
|
|
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 (unless `extra="allow"` is set).
|
|
- If config validation fails, Hydrogen reports a `PluginRuntimeError` and continues with other modules — your worker is not created.
|
|
|
|
## Category-Based Exclusion
|
|
|
|
Hydrogen can disable modules by manifest category.
|
|
|
|
Example:
|
|
|
|
```yaml
|
|
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`:
|
|
|
|
```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:
|
|
|
|
```python
|
|
import modules.my_check
|
|
```
|
|
|
|
Therefore:
|
|
|
|
- The module directory must be importable as a Python package.
|
|
- Its `__init__.py` is 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:
|
|
|
|
```yaml
|
|
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 a `BaseWorker`.
|
|
|
|
**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_version` mismatch).
|
|
- Missing `CONFIG_MODEL` export with invalid type.
|
|
|
|
## Duplicate Module Detection
|
|
|
|
Hydrogen detects duplicate module identifiers during config resolution:
|
|
|
|
```python
|
|
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:
|
|
|
|
1. Define a `CONFIG_MODEL` Pydantic model for type-safe config validation.
|
|
2. Keep `__init__.py` small — just `MANIFEST`, `CONFIG_MODEL`, and `build_worker()`.
|
|
3. Put most logic in a separate worker module.
|
|
4. Set `config_model` on your worker class for consistency.
|
|
5. Use stable, machine-friendly finding names.
|
|
6. Return `skipped` when the check is not applicable on the current platform.
|
|
7. Use `api_version="1"` (match Hydrogen's `HYDROGEN_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:
|
|
|
|
```bash
|
|
python main.py --path custom_checks --package-prefix custom_checks
|
|
```
|
|
|
|
Then Hydrogen expects packages like:
|
|
|
|
```text
|
|
custom_checks/
|
|
__init__.py
|
|
my_check/
|
|
__init__.py
|
|
config.py
|
|
worker.py
|
|
```
|
|
|
|
Alternatively, add your package to `plugin_packages.modules`:
|
|
|
|
```yaml
|
|
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:
|
|
|
|
1. The package imports cleanly.
|
|
2. `MANIFEST` validates successfully (especially `api_version`).
|
|
3. `CONFIG_MODEL` (if defined) validates correctly.
|
|
4. `build_worker()` returns a real `BaseWorker` instance.
|
|
5. `run()` always returns `AuditResults`.
|
|
6. All findings use valid `AuditSeverity` values.
|
|
7. The module behaves correctly when its config section is missing.
|
|
8. The module behaves correctly when it is excluded by category.
|
|
9. 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_version` matching Hydrogen's.
|
|
- Optional `CONFIG_MODEL` for 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.
|